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 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 getRenderTypes = function() {
 17         return [
 18             {
 19                 name: 'bar_chart',
 20                 title: 'Bar',
 21                 imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png',
 22                 fields: [
 23                     {name: 'x', label: 'X Axis Categories', required: true, nonNumericOnly: true},
 24                     {name: 'xSub', label: 'Split Categories By', required: false, nonNumericOnly: true},
 25                     {name: 'y', label: 'Y Axis', numericOnly: true}
 26                 ],
 27                 layoutOptions: {line: true, opacity: true, axisBased: true}
 28             },
 29             {
 30                 name: 'box_plot',
 31                 title: 'Box',
 32                 imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png',
 33                 fields: [
 34                     {name: 'x', label: 'X Axis Categories'},
 35                     {name: 'y', label: 'Y Axis', required: true, numericOnly: true},
 36                     {name: 'color', label: 'Color', nonNumericOnly: true},
 37                     {name: 'shape', label: 'Shape', nonNumericOnly: true}
 38                 ],
 39                 layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true}
 40             },
 41             {
 42                 name: 'pie_chart',
 43                 title: 'Pie',
 44                 imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png',
 45                 fields: [
 46                     {name: 'x', label: 'Categories', required: true, nonNumericOnly: true},
 47                     // Issue #29046  'Remove "measure" option from pie chart'
 48                     // {name: 'y', label: 'Measure', numericOnly: true}
 49                 ],
 50                 layoutOptions: {pie: true}
 51             },
 52             {
 53                 name: 'scatter_plot',
 54                 title: 'Scatter',
 55                 imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png',
 56                 fields: [
 57                     {name: 'x', label: 'X Axis', required: true},
 58                     {name: 'y', label: 'Y Axis', required: true, numericOnly: true},
 59                     {name: 'color', label: 'Color', nonNumericOnly: true},
 60                     {name: 'shape', label: 'Shape', nonNumericOnly: true}
 61                 ],
 62                 layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true}
 63             },
 64             {
 65                 name: 'time_chart',
 66                 title: 'Time',
 67                 hidden: _getStudyTimepointType() == null,
 68                 imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png',
 69                 fields: [
 70                     {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'},
 71                     {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}
 72                 ],
 73                 layoutOptions: {time: true, axisBased: true}
 74             }
 75         ];
 76     };
 77 
 78     /**
 79      * Gets the chart type (i.e. box or scatter).
 80      * @param {String} renderType The selected renderType, this can be SCATTER_PLOT, BOX_PLOT, or BAR_CHART. Determined
 81      * at chart creation time in the Generic Chart Wizard.
 82      * @param {String} xAxisType The datatype of the x-axis, i.e. String, Boolean, Number.
 83      * @returns {String}
 84      */
 85     var getChartType = function(renderType, xAxisType)
 86     {
 87         if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart"
 88             || renderType === "box_plot" || renderType === "scatter_plot")
 89         {
 90             return renderType;
 91         }
 92 
 93         if (!xAxisType)
 94         {
 95             // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for
 96             // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require
 97             // an x-axis measure.
 98             return 'box_plot';
 99         }
100 
101         return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot';
102     };
103 
104     /**
105      * Generate a default label for the selected measure for the given renderType.
106      * @param renderType
107      * @param measureName - the chart type's measure name
108      * @param properties - properties for the selected column
109      */
110     var getSelectedMeasureLabel = function(renderType, measureName, properties)
111     {
112         var label = properties ? properties.label || properties.queryName : '';
113 
114         if (label != '' && measureName == 'y' && (renderType == 'bar_chart' || renderType == 'pie_chart')) {
115             if (Ext4.isDefined(properties.aggregate)) {
116                 var aggLabel = Ext4.isObject(properties.aggregate) ? properties.aggregate.name
117                         : Ext4.String.capitalize(properties.aggregate.toLowerCase());
118                 label = aggLabel + ' of ' + label;
119             }
120             else {
121                 label = 'Sum of ' + label;
122             }
123         }
124 
125         return label;
126     };
127 
128     /**
129      * Generate a plot title based on the selected measures array or object.
130      * @param renderType
131      * @param measures
132      * @returns {string}
133      */
134     var getTitleFromMeasures = function(renderType, measures)
135     {
136         var queryLabels = [];
137 
138         if (Ext4.isObject(measures))
139         {
140             if (Ext4.isArray(measures.y))
141             {
142                 Ext4.each(measures.y, function(m)
143                 {
144                     var measureQueryLabel = m.queryLabel || m.queryName;
145                     if (queryLabels.indexOf(measureQueryLabel) == -1)
146                         queryLabels.push(measureQueryLabel);
147                 });
148             }
149             else
150             {
151                 var m = measures.x || measures.y;
152                 queryLabels.push(m.queryLabel || m.queryName);
153             }
154         }
155 
156         return queryLabels.join(', ');
157     };
158 
159     /**
160      * Get the sorted set of column metadata for the given schema/query/view.
161      * @param queryConfig
162      * @param successCallback
163      * @param callbackScope
164      */
165     var getQueryColumns = function(queryConfig, successCallback, callbackScope)
166     {
167         LABKEY.Ajax.request({
168             url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'),
169             method: 'GET',
170             params: {
171                 schemaName: queryConfig.schemaName,
172                 queryName: queryConfig.queryName,
173                 viewName: queryConfig.viewName,
174                 dataRegionName: queryConfig.dataRegionName,
175                 includeCohort: true,
176                 includeParticipantCategory : true
177             },
178             success : function(response){
179                 var columnList = Ext4.decode(response.responseText);
180                 _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope)
181             },
182             scope   : this
183         });
184     };
185 
186     var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope)
187     {
188         LABKEY.Query.selectRows({
189             maxRows: 0, // use maxRows 0 so that we just get the query metadata
190             schemaName: queryConfig.schemaName,
191             queryName: queryConfig.queryName,
192             viewName: queryConfig.viewName,
193             parameters: queryConfig.parameters,
194             requiredVersion: 9.1,
195             columns: columnList.columns.all,
196             success: function(response){
197                 var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields);
198                 successCallback.call(callbackScope, columnMetadata);
199             },
200             failure : function(response) {
201                 // this likely means that the query no longer exists
202                 successCallback.call(callbackScope, columnList, []);
203             },
204             scope   : this
205         });
206     };
207 
208     var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata)
209     {
210         var queryFields = [],
211             columnTypes = Ext4.isDefined(columnList.columns) ? columnList.columns : {};
212 
213         Ext4.each(columnMetadata, function(column)
214         {
215             var f = Ext4.clone(column);
216             f.schemaName = queryConfig.schemaName;
217             f.queryName = queryConfig.queryName;
218             f.isCohortColumn = false;
219             f.isSubjectGroupColumn = false;
220 
221             // issue 23224: distinguish cohort and subject group fields in the list of query columns
222             if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1)
223             {
224                 f.shortCaption = 'Study: ' + f.shortCaption;
225                 f.isCohortColumn = true;
226             }
227             else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1)
228             {
229                 f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption;
230                 f.isSubjectGroupColumn = true;
231             }
232 
233             if (f.fieldKey.toLowerCase() != 'lsid')
234                 queryFields.push(f);
235         }, this);
236 
237         // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end.
238         queryFields.sort(function(a, b)
239         {
240             if (a.isSubjectGroupColumn != b.isSubjectGroupColumn)
241                 return a.isSubjectGroupColumn ? 1 : -1;
242             else if (a.isCohortColumn != b.isCohortColumn)
243                 return a.isCohortColumn ? 1 : -1;
244             else if (a.shortCaption != b.shortCaption)
245                 return a.shortCaption < b.shortCaption ? -1 : 1;
246 
247             return 0;
248         });
249 
250         return queryFields;
251     };
252 
253     /**
254      * Determine a reasonable width for the chart based on the chart type and selected measures / data.
255      * @param chartType
256      * @param measures
257      * @param measureStore
258      * @param defaultWidth
259      * @returns {int}
260      */
261     var getChartTypeBasedWidth = function(chartType, measures, measureStore, defaultWidth) {
262         var width = defaultWidth;
263 
264         if (chartType == 'bar_chart' && Ext4.isObject(measures.x)) {
265             // 15px per bar + 15px between bars + 300 for default margins
266             var xBarCount = measureStore.members(measures.x.name).length;
267             width = Math.max((xBarCount * 15 * 2) + 300, defaultWidth);
268 
269             if (Ext4.isObject(measures.xSub)) {
270                 // 15px per bar per group + 200px between groups + 600 for default margins
271                 var xSubCount = measureStore.members(measures.xSub.name).length;
272                 width = (xBarCount * xSubCount * 15) + (xSubCount * 200) + 600;
273             }
274         }
275         else if (chartType == 'box_plot' && Ext4.isObject(measures.x)) {
276             // 20px per box + 20px between boxes + 300 for default margins
277             var xBoxCount = measureStore.members(measures.x.name).length;
278             width = Math.max((xBoxCount * 20 * 2) + 300, defaultWidth);
279         }
280 
281         return width;
282     };
283 
284     /**
285      * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults
286      * to empty string ('').
287      * @param {Object} labels The saved labels object.
288      * @returns {Object}
289      */
290     var generateLabels = function(labels) {
291         return {
292             main: {
293                 value: labels.main ? labels.main : ''
294             },
295             subtitle: {
296                 value: labels.subtitle ? labels.subtitle : ''
297             },
298             footer: {
299                 value: labels.footer ? labels.footer : ''
300             },
301             x: {
302                 value: labels.x ? labels.x : ''
303             },
304             y: {
305                 value: labels.y ? labels.y : ''
306             }
307         };
308     };
309 
310     /**
311      * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart.
312      * @param {String} chartType The chartType from getChartType.
313      * @param {Object} measures The measures from generateMeasures.
314      * @param {Object} savedScales The scales object from the saved chart config.
315      * @param {Object} aes The aesthetic map object from genereateAes.
316      * @param {Object} measureStore The MeasureStore data using a selectRows API call.
317      * @param {Function} defaultFormatFn used to format values for tick marks.
318      * @returns {Object}
319      */
320     var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) {
321         var scales = {};
322         var data = Ext4.isArray(measureStore.rows) ? measureStore.rows : measureStore.records();
323         var fields = Ext4.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields;
324         var subjectColumn = _getStudySubjectInfo().columnName;
325         var valExponentialDigits = 6;
326 
327         if (chartType === "box_plot")
328         {
329             scales.x = {
330                 scaleType: 'discrete', // Force discrete x-axis scale for box plots.
331                 sortFn: LABKEY.vis.discreteSortFn,
332                 tickLabelMax: 25
333             };
334 
335             var yMin = d3.min(data, aes.y);
336             var yMax = d3.max(data, aes.y);
337             var yPadding = ((yMax - yMin) * .1);
338             if (savedScales.y && savedScales.y.trans == "log")
339             {
340                 // When subtracting padding we have to make sure we still produce valid values for a log scale.
341                 // log([value less than 0]) = NaN.
342                 // log(0) = -Infinity.
343                 if (yMin - yPadding > 0)
344                 {
345                     yMin = yMin - yPadding;
346                 }
347             }
348             else
349             {
350                 yMin = yMin - yPadding;
351             }
352 
353             scales.y = {
354                 min: yMin,
355                 max: yMax + yPadding,
356                 scaleType: 'continuous',
357                 trans: savedScales.y ? savedScales.y.trans : 'linear'
358             };
359         }
360         else
361         {
362             var xMeasureType = getMeasureType(measures.x);
363 
364             // Force discrete x-axis scale for bar plots.
365             var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType);
366 
367             if (useContinuousScale)
368             {
369                 scales.x = {
370                     scaleType: 'continuous',
371                     trans: savedScales.x ? savedScales.x.trans : 'linear'
372                 };
373             }
374             else
375             {
376                 scales.x = {
377                     scaleType: 'discrete',
378                     sortFn: LABKEY.vis.discreteSortFn,
379                     tickLabelMax: 25
380                 };
381 
382                 //bar chart x-axis subcategories support
383                 if (Ext4.isDefined(measures.xSub)) {
384                     scales.xSub = {
385                         scaleType: 'discrete',
386                         sortFn: LABKEY.vis.discreteSortFn,
387                         tickLabelMax: 25
388                     };
389                 }
390             }
391 
392             scales.y = {
393                 scaleType: 'continuous',
394                 trans: savedScales.y ? savedScales.y.trans : 'linear'
395             };
396         }
397 
398         // if we have no data, show a default y-axis domain
399         if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous')
400             scales.x.domain = [0,1];
401         if (scales.y && data.length == 0)
402             scales.y.domain = [0,1];
403 
404         for (var i = 0; i < fields.length; i++) {
405             var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type;
406             var isMeasureXMatch = measures.x && (fields[i].fieldKey == measures.x.name || fields[i].fieldKey == measures.x.fieldKey);
407             var isMeasureYMatch = measures.y && (fields[i].fieldKey == measures.y.name || fields[i].fieldKey == measures.y.fieldKey);
408             var isConvertedYMeasure = isMeasureYMatch && measures.y.converted;
409 
410             if (isNumericType(type) || isConvertedYMeasure) {
411                 if (isMeasureXMatch) {
412                     if (fields[i].extFormatFn) {
413                         scales.x.tickFormat = eval(fields[i].extFormatFn);
414                     }
415                     else if (defaultFormatFn) {
416                         scales.x.tickFormat = defaultFormatFn;
417                     }
418                 }
419 
420                 if (isMeasureYMatch) {
421                     var tickFormatFn;
422 
423                     if (fields[i].extFormatFn) {
424                         tickFormatFn = eval(fields[i].extFormatFn);
425                     }
426                     else if (defaultFormatFn) {
427                         tickFormatFn = defaultFormatFn;
428                     }
429 
430                     scales.y.tickFormat = function(value) {
431                         if (Ext4.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) {
432                             return value.toExponential();
433                         }
434                         else if (Ext4.isFunction(tickFormatFn)) {
435                             return tickFormatFn(value);
436                         }
437                         return value;
438                     };
439                 }
440             }
441             else if (isMeasureXMatch && measures.x.name == subjectColumn && LABKEY.demoMode) {
442                     scales.x.tickFormat = function(){return '******'};
443             }
444         }
445 
446         if (savedScales.x && (savedScales.x.min != null || savedScales.x.max != null)) {
447             scales.x.domain = [savedScales.x.min, savedScales.x.max];
448         }
449 
450         if (Ext4.isDefined(measures.xSub) && savedScales.xSub && (savedScales.xSub.min != null || savedScales.xSub.max != null)) {
451             scales.xSub.domain = [savedScales.xSub.min, savedScales.xSub.max];
452         }
453 
454         if (savedScales.y && (savedScales.y.min != null || savedScales.y.max != null)) {
455             scales.y.domain = [savedScales.y.min, savedScales.y.max];
456         }
457 
458         return scales;
459     };
460 
461     /**
462      * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot}
463      * and {@link LABKEY.vis.Layer}.
464      * @param {String} chartType The chartType from getChartType.
465      * @param {Object} measures The measures from getMeasures.
466      * @param {String} schemaName The schemaName from the saved queryConfig.
467      * @param {String} queryName The queryName from the saved queryConfig.
468      * @returns {Object}
469      */
470     var generateAes = function(chartType, measures, schemaName, queryName) {
471         var aes = {},
472             xMeasureType = getMeasureType(measures.x);
473 
474         if (chartType == "box_plot" && !measures.x)
475         {
476             aes.x = generateMeasurelessAcc(queryName);
477         }
478         else if (isNumericType(xMeasureType) || (chartType == 'scatter_plot' && measures.x.measure))
479         {
480             var xMeasureName = measures.x.converted ? measures.x.convertedName : measures.x.name;
481             aes.x = generateContinuousAcc(xMeasureName);
482         }
483         else
484         {
485             var xMeasureName = measures.x.converted ? measures.x.convertedName : measures.x.name;
486             aes.x = generateDiscreteAcc(xMeasureName, measures.x.label);
487         }
488 
489         if (measures.y)
490         {
491             var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name;
492             aes.y = generateContinuousAcc(yMeasureName);
493         }
494 
495         if (chartType === "scatter_plot")
496         {
497             aes.hoverText = generatePointHover(measures);
498         }
499         if (chartType === "box_plot")
500         {
501             if (measures.color) {
502                 aes.outlierColor = generateGroupingAcc(measures.color.name);
503             }
504 
505             if (measures.shape) {
506                 aes.outlierShape = generateGroupingAcc(measures.shape.name);
507             }
508 
509             aes.hoverText = generateBoxplotHover();
510             aes.outlierHoverText = generatePointHover(measures);
511         }
512         else if (chartType === 'bar_chart')
513         {
514             var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null;
515             if (xSubMeasureType)
516             {
517                 if (isNumericType(xSubMeasureType))
518                     aes.xSub = generateContinuousAcc(measures.xSub.name);
519                 else
520                     aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label);
521             }
522         }
523 
524 
525         // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we
526         // create a second layer for points. So we'll need this no matter what.
527         if (measures.color) {
528             aes.color = generateGroupingAcc(measures.color.name);
529         }
530 
531         if (measures.shape) {
532             aes.shape = generateGroupingAcc(measures.shape.name);
533         }
534 
535         if (measures.pointClickFn) {
536             aes.pointClickFn = generatePointClickFn(
537                     measures,
538                     schemaName,
539                     queryName,
540                     measures.pointClickFn
541             );
542         }
543 
544         return aes;
545     };
546 
547     /**
548      * Generates a function that returns the text used for point hovers.
549      * @param {Object} measures The measures object from the saved chart config.
550      * @returns {Function}
551      */
552     var generatePointHover = function(measures)
553     {
554         return function(row) {
555             var hover = '', sep = '';
556 
557             if (measures.x)
558             {
559                 hover += sep + measures.x.label + ': ' + _getRowValue(row, measures.x.name);
560                 sep = ', \n';
561             }
562             hover += sep + measures.y.label + ': ' + _getRowValue(row, measures.y.name);
563             sep = ', \n';
564 
565             if (measures.color)
566                 hover += sep + measures.color.label + ': ' + _getRowValue(row, measures.color.name);
567             if(measures.shape && !(measures.color && measures.color.name == measures.shape.name))
568                 hover += sep + measures.shape.label + ': ' + _getRowValue(row, measures.shape.name);
569 
570             return hover;
571         };
572     };
573 
574     /**
575      * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData.
576      */
577     var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) {
578         return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false);
579     };
580 
581     var _getRowValue = function(row, propName, valueName)
582     {
583         if (row.hasOwnProperty(propName)) {
584             // backwards compatibility for response row that is not a LABKEY.Query.Row
585             if (!(row instanceof LABKEY.Query.Row)) {
586                 return row[propName].displayValue || row[propName].value;
587             }
588 
589             var propValue = row.get(propName);
590             if (valueName != undefined && propValue.hasOwnProperty(valueName)) {
591                 return propValue[valueName];
592             }
593             else if (propValue.hasOwnProperty('displayValue')) {
594                 return propValue['displayValue'];
595             }
596             return row.getValue(propName);
597         }
598 
599         return undefined;
600     };
601 
602     /**
603      * Returns a function used to generate the hover text for box plots.
604      * @returns {Function}
605      */
606     var generateBoxplotHover = function() {
607         return function(xValue, stats) {
608             return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 +
609                     '\nQ3: ' + stats.Q3;
610         };
611     };
612 
613     /**
614      * Generates an accessor function that returns a discrete value from a row of data for a given measure and label.
615      * Used when an axis has a discrete measure (i.e. string).
616      * @param {String} measureName The name of the measure.
617      * @param {String} measureLabel The label of the measure.
618      * @returns {Function}
619      */
620     var generateDiscreteAcc = function(measureName, measureLabel)
621     {
622         return function(row)
623         {
624             var value = _getRowValue(row, measureName);
625             if (value === null)
626                 value = "Not in " + measureLabel;
627 
628             return value;
629         };
630     };
631 
632     /**
633      * Generates an accessor function that returns a value from a row of data for a given measure.
634      * @param {String} measureName The name of the measure.
635      * @returns {Function}
636      */
637     var generateContinuousAcc = function(measureName)
638     {
639         return function(row)
640         {
641             var value = _getRowValue(row, measureName, 'value');
642 
643             if (value !== undefined)
644             {
645                 if (Math.abs(value) === Infinity)
646                     value = null;
647 
648                 if (value === false || value === true)
649                     value = value.toString();
650 
651                 return value;
652             }
653 
654             return undefined;
655         }
656     };
657 
658     /**
659      * Generates an accesssor function for shape and color measures.
660      * @param {String} measureName The name of the measure.
661      * @returns {Function}
662      */
663     var generateGroupingAcc = function(measureName)
664     {
665         return function(row)
666         {
667             var value = _getRowValue(row, measureName);
668             if (value === null || value === undefined)
669                 value = "n/a";
670 
671             return value;
672         };
673     };
674 
675     /**
676      * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the
677      * queryName.
678      * @param {String} measureName The name of the measure. In this case it is generally the query name.
679      * @returns {Function}
680      */
681     var generateMeasurelessAcc = function(measureName) {
682         // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row.
683         return function(row) {
684             return measureName;
685         }
686     };
687 
688     /**
689      * Generates the function to be executed when a user clicks a point.
690      * @param {Object} measures The measures from the saved chart config.
691      * @param {String} schemaName The schema name from the saved query config.
692      * @param {String} queryName The query name from the saved query config.
693      * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked.
694      * @returns {Function}
695      */
696     var generatePointClickFn = function(measures, schemaName, queryName, fnString){
697         var measureInfo = {
698             schemaName: schemaName,
699             queryName: queryName
700         };
701 
702         if (measures.y)
703             measureInfo.yAxis = measures.y.name;
704         if (measures.x)
705             measureInfo.xAxis = measures.x.name;
706         if (measures.shape)
707             measureInfo.shapeName = measures.shape.name;
708         if (measures.color)
709             measureInfo.pointName = measures.color.name;
710 
711         // using new Function is quicker than eval(), even in IE.
712         var pointClickFn = new Function('return ' + fnString)();
713         return function(clickEvent, data){
714             pointClickFn(data, measureInfo, clickEvent);
715         };
716     };
717 
718     /**
719      * Generates the Point Geom used for scatter plots and box plots with all points visible.
720      * @param {Object} chartOptions The saved chartOptions object from the chart config.
721      * @returns {LABKEY.vis.Geom.Point}
722      */
723     var generatePointGeom = function(chartOptions){
724         return new LABKEY.vis.Geom.Point({
725             opacity: chartOptions.opacity,
726             size: chartOptions.pointSize,
727             color: '#' + chartOptions.pointFillColor,
728             position: chartOptions.position
729         });
730     };
731 
732     /**
733      * Generates the Boxplot Geom used for box plots.
734      * @param {Object} chartOptions The saved chartOptions object from the chart config.
735      * @returns {LABKEY.vis.Geom.Boxplot}
736      */
737     var generateBoxplotGeom = function(chartOptions){
738         return new LABKEY.vis.Geom.Boxplot({
739             lineWidth: chartOptions.lineWidth,
740             outlierOpacity: chartOptions.opacity,
741             outlierFill: '#' + chartOptions.pointFillColor,
742             outlierSize: chartOptions.pointSize,
743             color: '#' + chartOptions.lineColor,
744             fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor,
745             position: chartOptions.position,
746             showOutliers: chartOptions.showOutliers
747         });
748     };
749 
750     /**
751      * Generates the Barplot Geom used for bar charts.
752      * @param {Object} chartOptions The saved chartOptions object from the chart config.
753      * @returns {LABKEY.vis.Geom.BarPlot}
754      */
755     var generateBarGeom = function(chartOptions){
756         return new LABKEY.vis.Geom.BarPlot({
757             opacity: chartOptions.opacity,
758             color: '#' + chartOptions.lineColor,
759             fill: '#' + chartOptions.boxFillColor,
760             lineWidth: chartOptions.lineWidth
761         });
762     };
763 
764     /**
765      * Generates the Bin Geom used to bin a set of points.
766      * @param {Object} chartOptions The saved chartOptions object from the chart config.
767      * @returns {LABKEY.vis.Geom.Bin}
768      */
769     var generateBinGeom = function(chartOptions) {
770         var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default
771         if (chartOptions.binColorGroup == 'SingleColor') {
772             var color = '#' + chartOptions.binSingleColor;
773             colorRange = ["#FFFFFF", color];
774         }
775         else if (chartOptions.binColorGroup == 'Heat') {
776             colorRange = ["#fff6bc", "#e23202"];
777         }
778 
779         return new LABKEY.vis.Geom.Bin({
780             shape: chartOptions.binShape,
781             colorRange: colorRange,
782             size: chartOptions.binShape == 'square' ? 10 : 5
783         })
784     };
785 
786     /**
787      * Generates a Geom based on the chartType.
788      * @param {String} chartType The chart type from getChartType.
789      * @param {Object} chartOptions The chartOptions object from the saved chart config.
790      * @returns {LABKEY.vis.Geom}
791      */
792     var generateGeom = function(chartType, chartOptions) {
793         if (chartType == "box_plot")
794             return generateBoxplotGeom(chartOptions);
795         else if (chartType == "scatter_plot")
796             return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions);
797         else if (chartType == "bar_chart")
798             return generateBarGeom(chartOptions);
799     };
800 
801     /**
802      * Generate the plot config for the given chart renderType and config options.
803      * @param renderTo
804      * @param chartConfig
805      * @param labels
806      * @param aes
807      * @param scales
808      * @param geom
809      * @param data
810      * @returns {Object}
811      */
812     var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data)
813     {
814         var renderType = chartConfig.renderType,
815             layers = [], clipRect,
816             plotConfig = {
817                 renderTo: renderTo,
818                 rendererType: 'd3',
819                 width: chartConfig.width,
820                 height: chartConfig.height
821             };
822 
823         if (renderType == 'pie_chart')
824             return _generatePieChartConfig(plotConfig, chartConfig, labels, data);
825 
826         clipRect = (scales.x && Ext4.isArray(scales.x.domain)) || (scales.y && Ext4.isArray(scales.y.domain));
827 
828         if (renderType == 'bar_chart')
829         {
830             aes = { x: 'label', y: 'value' };
831 
832             if (Ext4.isDefined(chartConfig.measures.xSub))
833             {
834                 aes.xSub = 'subLabel';
835                 aes.color = 'label';
836             }
837 
838             if (!scales.y) {
839                 scales.y = {};
840             }
841 
842             if (!scales.y.domain) {
843                 var values = Ext4.Array.pluck(data, 'value'),
844                     min = Math.min(0, Ext4.Array.min(values)),
845                     max = Math.max(0, Ext4.Array.max(values));
846 
847                 scales.y.domain = [min, max];
848             }
849         }
850         else if (renderType == 'box_plot' && chartConfig.pointType == 'all')
851         {
852             layers.push(
853                 new LABKEY.vis.Layer({
854                     data: data,
855                     geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions),
856                     aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)}
857                 })
858             );
859         }
860 
861         var margins = _getPlotMargins(renderType, aes, data, plotConfig);
862         if (Ext4.isObject(margins)) {
863             plotConfig.margins = margins;
864         }
865 
866         if (chartConfig.measures.color)
867         {
868             scales.color = {
869                 colorType: chartConfig.geomOptions.colorPaletteScale,
870                 scaleType: 'discrete'
871             }
872         }
873 
874         layers.push(
875             new LABKEY.vis.Layer({
876                 data: data,
877                 geom: geom
878             })
879         );
880 
881         plotConfig = Ext4.apply(plotConfig, {
882             clipRect: clipRect,
883             data: data,
884             labels: labels,
885             aes: aes,
886             scales: scales,
887             layers: layers
888         });
889 
890         return plotConfig;
891     };
892 
893     var _getPlotMargins = function(renderType, aes, data, plotConfig) {
894         // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length
895         if (Ext4.isArray(data) && ((renderType == 'bar_chart' && !Ext4.isDefined(aes.xSub)) || renderType == 'box_plot')) {
896             var maxLen = 0;
897             Ext4.each(data, function(d) {
898                 var val = Ext4.isFunction(aes.x) ? aes.x(d) : d[aes.x];
899                 if (Ext4.isString(val)) {
900                     maxLen = Math.max(maxLen, val.length);
901                 }
902             });
903 
904             if (data.length * maxLen*5 > plotConfig.width) {
905                 // min bottom margin: 50, max bottom margin: 275
906                 var bottomMargin = Math.min(Math.max(50, maxLen*5), 275);
907                 return {bottom: bottomMargin};
908             }
909         }
910 
911         return null;
912     };
913 
914     var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data)
915     {
916         var hasData = data.length > 0;
917 
918         return Ext4.apply(baseConfig, {
919             data: hasData ? data : [{label: '', value: 1}],
920             header: {
921                 title: { text: labels.main.value },
922                 subtitle: { text: labels.subtitle.value },
923                 titleSubtitlePadding: 1
924             },
925             footer: {
926                 text: hasData ? labels.footer.value : 'No data to display',
927                 location: 'bottom-center'
928             },
929             labels: {
930                 mainLabel: { fontSize: 14 },
931                 percentage: {
932                     fontSize: 14,
933                     color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined
934                 },
935                 outer: { pieDistance: 20 },
936                 inner: {
937                     format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none',
938                     hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage
939                 }
940             },
941             size: {
942                 pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%',
943                 pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%'
944             },
945             misc: {
946                 gradient: {
947                     enabled: chartConfig.geomOptions.gradientPercentage != 0,
948                     percentage: chartConfig.geomOptions.gradientPercentage,
949                     color: '#' + chartConfig.geomOptions.gradientColor
950                 },
951                 colors: {
952                     segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333']
953                 }
954             },
955             effects: { highlightSegmentOnMouseover: false },
956             tooltips: { enabled: true }
957         });
958     };
959 
960     /**
961      * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists.
962      * @param measureStore
963      * @param includeFilterMsg true to include a message about removing filters
964      * @returns {String}
965      */
966     var validateResponseHasData = function(measureStore, includeFilterMsg)
967     {
968         var dataArray = Ext4.isDefined(measureStore) ? measureStore.rows || measureStore.records() : [];
969         if (dataArray.length == 0)
970         {
971             return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.'
972                 + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : '');
973         }
974 
975         return null;
976     };
977 
978     /**
979      * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log
980      * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the
981      * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart
982      * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success
983      * is true, there is a warning.
984      * @param {String} chartType The chartType from getChartType.
985      * @param {Object} chartConfig The saved chartConfig object.
986      * @param {String} measureName The name of the axis measure property.
987      * @param {Object} aes The aes object from generateAes.
988      * @param {Object} scales The scales object from generateScales.
989      * @param {Array} data The response data from selectRows.
990      * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data
991      * @returns {Object}
992      */
993     var validateAxisMeasure = function(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened) {
994         var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null;
995 
996         // no need to check measures if we have no data
997         if (data.length == 0) {
998             return {success: true, message: message};
999         }
1000 
1001         for (var i = 0; i < data.length; i ++)
1002         {
1003             var value = aes[measureName](data[i]);
1004 
1005             if (value !== undefined)
1006                 measureUndefined = false;
1007 
1008             if (value !== null)
1009                 dataIsNull = false;
1010 
1011             if (value && value < 0)
1012                 invalidLogValues = true;
1013 
1014             if (value === 0 )
1015                 hasZeroes = true;
1016         }
1017 
1018         if (measureUndefined)
1019         {
1020             message = 'The measure ' + chartConfig.measures[measureName].label + ' was not found. It may have been renamed or removed.';
1021             return {success: false, message: message};
1022         }
1023 
1024         if ((chartType == 'scatter_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened)
1025         {
1026             message = 'All data values for ' + chartConfig.measures[measureName].label + ' are null. Please choose a different measure.';
1027             return {success: false, message: message};
1028         }
1029 
1030         if (scales[measureName] && scales[measureName].trans == "log")
1031         {
1032             if (invalidLogValues)
1033             {
1034                 message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName
1035                         + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis.";
1036                 scales[measureName].trans = 'linear';
1037             }
1038             else if (hasZeroes)
1039             {
1040                 message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1.";
1041                 var accFn = aes[measureName];
1042                 aes[measureName] = function(row){return accFn(row) + 1};
1043             }
1044         }
1045 
1046         return {success: true, message: message};
1047     };
1048 
1049     /**
1050      * Deprecated - use validateAxisMeasure
1051      */
1052     var validateXAxis = function(chartType, chartConfig, aes, scales, data){
1053         return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data);
1054     };
1055     /**
1056      * Deprecated - use validateAxisMeasure
1057      */
1058     var validateYAxis = function(chartType, chartConfig, aes, scales, data){
1059         return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data);
1060     };
1061 
1062     var getMeasureType = function(measure) {
1063         return measure ? (measure.normalizedType || measure.type) : null;
1064     };
1065 
1066     var isNumericType = function(type)
1067     {
1068         var t = Ext4.isString(type) ? type.toLowerCase() : null;
1069         return t == 'int' || t == 'integer' || t == 'float' || t == 'double';
1070     };
1071 
1072     var _getStudySubjectInfo = function()
1073     {
1074         var studyCtx = LABKEY.getModuleContext("study") || {};
1075         return Ext4.isObject(studyCtx.subject) ? studyCtx.subject : {
1076             tableName: 'Participant',
1077             columnName: 'ParticipantId',
1078             nounPlural: 'Participants',
1079             nounSingular: 'Participant'
1080         };
1081     };
1082 
1083     var _getStudyTimepointType = function()
1084     {
1085         var studyCtx = LABKEY.getModuleContext("study") || {};
1086         return Ext4.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null;
1087     };
1088 
1089     var _getMeasureRestrictions = function (chartType, measure)
1090     {
1091         var measureRestrictions = {};
1092         Ext4.each(getRenderTypes(), function (renderType)
1093         {
1094             if (renderType.name === chartType)
1095             {
1096                 Ext4.each(renderType.fields, function (field)
1097                 {
1098                     if (field.name === measure)
1099                     {
1100                         measureRestrictions.numericOnly = field.numericOnly;
1101                         measureRestrictions.nonNumericOnly = field.nonNumericOnly;
1102                         return false;
1103                     }
1104                 });
1105                 return false;
1106             }
1107         });
1108 
1109         return measureRestrictions;
1110     };
1111 
1112     /**
1113      * Converts data values passed in to the appropriate type based on measure/dimension information.
1114      * @param chartConfig Chart configuration object
1115      * @param aes Aesthetic mapping functions for each measure/axis
1116      * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart)
1117      * @param data The response data from SelectRows
1118      * @returns {{processed: {}, warningMessage: *}}
1119      */
1120     var doValueConversion = function(chartConfig, aes, renderType, data)
1121     {
1122         var measuresForProcessing = {}, measureRestrictions = {}, configMeasure;
1123         for (var measureName in chartConfig.measures) {
1124             if (chartConfig.measures.hasOwnProperty(measureName) && Ext4.isObject(chartConfig.measures[measureName])) {
1125                 configMeasure = chartConfig.measures[measureName];
1126                 Ext4.apply(measureRestrictions, _getMeasureRestrictions(renderType, measureName));
1127 
1128                 var isColorOrShape = measureName === 'color' || measureName === 'shape';
1129                 var isXAxis = measureName === 'x' || measureName === 'xSub';
1130                 var isScatter = renderType === 'scatter_plot';
1131                 var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT');
1132 
1133                 if (configMeasure.measure && !isColorOrShape && !isBarYCount
1134                         && ((!isXAxis && measureRestrictions.numericOnly ) || isScatter) && !isNumericType(configMeasure.type)) {
1135                     measuresForProcessing[measureName] = {};
1136                     measuresForProcessing[measureName].name = configMeasure.name;
1137                     measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted";
1138                     measuresForProcessing[measureName].label = configMeasure.label;
1139                     configMeasure.normalizedType = 'float';
1140                     configMeasure.type = 'float';
1141                 }
1142             }
1143         }
1144 
1145         var response = {processed: {}};
1146         if (!Ext4.Object.isEmpty(measuresForProcessing)) {
1147             response = _processMeasureData(data, aes, measuresForProcessing);
1148         }
1149 
1150         //generate error message for dropped values
1151         var warningMessage = '';
1152         for (var measure in response.droppedValues) {
1153             if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) {
1154                 warningMessage += " The "
1155                         + measure + "-axis measure '"
1156                         + response.droppedValues[measure].label + "' had "
1157                         + response.droppedValues[measure].numDropped +
1158                         " value(s) that could not be converted to a number and are not included in the plot.";
1159             }
1160         }
1161 
1162         return {processed: response.processed, warningMessage: warningMessage};
1163     };
1164 
1165     /**
1166      * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only
1167      * attempt to convert strings to numbers for measures.
1168      * @param rows Data from SelectRows
1169      * @param aes Aesthetic mapping function for the measure/dimensions
1170      * @param measuresForProcessing The measures to be converted, if any
1171      * @returns {{droppedValues: {}, processed: {}}}
1172      */
1173     var _processMeasureData = function(rows, aes, measuresForProcessing) {
1174         var droppedValues = {}, processedMeasures = {}, dataIsNull;
1175         rows.forEach(function(row) {
1176             //convert measures if applicable
1177             if (!Ext4.Object.isEmpty(measuresForProcessing)) {
1178                 for (var measure in measuresForProcessing) {
1179                     if (measuresForProcessing.hasOwnProperty(measure)) {
1180                         dataIsNull = true;
1181                         if (!droppedValues[measure]) {
1182                             droppedValues[measure] = {};
1183                             droppedValues[measure].label = measuresForProcessing[measure].label;
1184                             droppedValues[measure].numDropped = 0;
1185                         }
1186 
1187                         if (aes.hasOwnProperty(measure)) {
1188                             var value = aes[measure](row);
1189                             if (value !== null) {
1190                                 dataIsNull = false;
1191                             }
1192                             row[measuresForProcessing[measure].convertedName] = {value: null};
1193                             if (typeof value !== 'number' && value !== null) {
1194 
1195                                 //only try to convert strings to numbers
1196                                 if (typeof value === 'string') {
1197                                     value = value.trim();
1198                                 }
1199                                 else {
1200                                     //dates, objects, booleans etc. to be assigned value: NULL
1201                                     value = '';
1202                                 }
1203 
1204                                 var n = Number(value);
1205                                 // empty strings convert to 0, which we must explicitly deny
1206                                 if (value === '' || isNaN(n)) {
1207                                     droppedValues[measure].numDropped++;
1208                                 }
1209                                 else {
1210                                     row[measuresForProcessing[measure].convertedName].value = n;
1211                                 }
1212                             }
1213                         }
1214 
1215                         if (!processedMeasures[measure]) {
1216                             processedMeasures[measure] = {
1217                                 converted: false,
1218                                 convertedName: measuresForProcessing[measure].convertedName,
1219                                 type: 'float',
1220                                 normalizedType: 'float'
1221                             }
1222                         }
1223 
1224                         processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull;
1225                     }
1226                 }
1227             }
1228         });
1229 
1230         return {droppedValues: droppedValues, processed: processedMeasures};
1231     };
1232 
1233     /**
1234      * removes all traces of String -> Numeric Conversion from the given chart config
1235      * @param chartConfig
1236      * @returns {updated ChartConfig}
1237      */
1238     var removeNumericConversionConfig = function(chartConfig) {
1239         if (chartConfig && chartConfig.measures) {
1240             for (var measureName in chartConfig.measures) {
1241                 if (chartConfig.measures.hasOwnProperty(measureName)) {
1242                     var measure = chartConfig.measures[measureName];
1243                     if (measure && measure.converted && measure.convertedName) {
1244                         measure.converted = null;
1245                         measure.convertedName = null;
1246                         if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) {
1247                             measure.type = 'string';
1248                             measure.normalizedType = 'string';
1249                         }
1250                     }
1251                 }
1252             }
1253         }
1254 
1255         return chartConfig;
1256     };
1257 
1258     return {
1259         // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't
1260         // ask me why, I do not know.
1261         /**
1262          * @function
1263          */
1264         getRenderTypes: getRenderTypes,
1265         getChartType: getChartType,
1266         getSelectedMeasureLabel: getSelectedMeasureLabel,
1267         getTitleFromMeasures: getTitleFromMeasures,
1268         getMeasureType: getMeasureType,
1269         getQueryColumns : getQueryColumns,
1270         getChartTypeBasedWidth : getChartTypeBasedWidth,
1271         isNumericType: isNumericType,
1272         generateLabels: generateLabels,
1273         generateScales: generateScales,
1274         generateAes: generateAes,
1275         doValueConversion: doValueConversion,
1276         removeNumericConversionConfig: removeNumericConversionConfig,
1277         generateAggregateData: generateAggregateData,
1278         generatePointHover: generatePointHover,
1279         generateBoxplotHover: generateBoxplotHover,
1280         generateDiscreteAcc: generateDiscreteAcc,
1281         generateContinuousAcc: generateContinuousAcc,
1282         generateGroupingAcc: generateGroupingAcc,
1283         generatePointClickFn: generatePointClickFn,
1284         generateGeom: generateGeom,
1285         generateBoxplotGeom: generateBoxplotGeom,
1286         generatePointGeom: generatePointGeom,
1287         generatePlotConfig: generatePlotConfig,
1288         validateResponseHasData: validateResponseHasData,
1289         validateAxisMeasure: validateAxisMeasure,
1290         validateXAxis: validateXAxis,
1291         validateYAxis: validateYAxis,
1292         /**
1293          * Loads all of the required dependencies for a Generic Chart.
1294          * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded.
1295          * @param {Object} scope The scope to be used when executing the callback.
1296          */
1297         loadVisDependencies: LABKEY.requiresVisualization
1298     };
1299 };