1 /* 2 * Copyright (c) 2013-2018 LabKey Corporation 3 * 4 * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 5 */ 6 if(!LABKEY.vis) { 7 LABKEY.vis = {}; 8 } 9 10 /** 11 * @namespace Namespace used to encapsulate functions related to creating 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 DEFULAT_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: DEFULAT_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: DEFULAT_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: DEFULAT_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); 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) { 1169 // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length 1170 if (LABKEY.Utils.isArray(data)) { 1171 var maxLen = 0; 1172 $.each(data, function(idx, d) { 1173 var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; 1174 if (LABKEY.Utils.isString(val)) { 1175 maxLen = Math.max(maxLen, val.length); 1176 } 1177 }); 1178 1179 if (_willRotateXAxisTickText(scales, plotConfig, maxLen, data)) { 1180 // min bottom margin: 50, max bottom margin: 275 1181 var bottomMargin = Math.min(Math.max(50, maxLen*5), 275); 1182 return {bottom: bottomMargin}; 1183 } 1184 } 1185 1186 return null; 1187 }; 1188 1189 var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) 1190 { 1191 var hasData = data.length > 0; 1192 1193 return $.extend(baseConfig, { 1194 data: hasData ? data : [{label: '', value: 1}], 1195 header: { 1196 title: { text: labels.main.value }, 1197 subtitle: { text: labels.subtitle.value }, 1198 titleSubtitlePadding: 1 1199 }, 1200 footer: { 1201 text: hasData ? labels.footer.value : 'No data to display', 1202 location: 'bottom-center' 1203 }, 1204 labels: { 1205 mainLabel: { fontSize: 14 }, 1206 percentage: { 1207 fontSize: 14, 1208 color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined 1209 }, 1210 outer: { pieDistance: 20 }, 1211 inner: { 1212 format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', 1213 hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage 1214 } 1215 }, 1216 size: { 1217 pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', 1218 pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' 1219 }, 1220 misc: { 1221 gradient: { 1222 enabled: chartConfig.geomOptions.gradientPercentage != 0, 1223 percentage: chartConfig.geomOptions.gradientPercentage, 1224 color: '#' + chartConfig.geomOptions.gradientColor 1225 }, 1226 colors: { 1227 segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] 1228 } 1229 }, 1230 effects: { highlightSegmentOnMouseover: false }, 1231 tooltips: { enabled: true } 1232 }); 1233 }; 1234 1235 /** 1236 * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. 1237 * @param measureStore 1238 * @param includeFilterMsg true to include a message about removing filters 1239 * @returns {String} 1240 */ 1241 var validateResponseHasData = function(measureStore, includeFilterMsg) 1242 { 1243 var dataArray = LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; 1244 if (dataArray.length == 0) 1245 { 1246 return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' 1247 + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); 1248 } 1249 1250 return null; 1251 }; 1252 1253 /** 1254 * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log 1255 * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the 1256 * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart 1257 * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success 1258 * is true, there is a warning. 1259 * @param {String} chartType The chartType from getChartType. 1260 * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. 1261 * @param {String} measureName The name of the axis measure property. 1262 * @param {Object} aes The aes object from generateAes. 1263 * @param {Object} scales The scales object from generateScales. 1264 * @param {Array} data The response data from selectRows. 1265 * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data 1266 * @returns {Object} 1267 */ 1268 var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { 1269 var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; 1270 return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); 1271 }; 1272 1273 var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { 1274 var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; 1275 1276 // no need to check measures if we have no data 1277 if (data.length === 0) { 1278 return {success: true, message: message}; 1279 } 1280 1281 for (var i = 0; i < data.length; i ++) 1282 { 1283 var value = aes[measureName](data[i]); 1284 1285 if (value !== undefined) 1286 measureUndefined = false; 1287 1288 if (value !== null) 1289 dataIsNull = false; 1290 1291 if (value && value < 0) 1292 invalidLogValues = true; 1293 1294 if (value === 0 ) 1295 hasZeroes = true; 1296 } 1297 1298 if (measureUndefined) 1299 { 1300 message = 'The measure ' + measure.label + ' was not found. It may have been renamed or removed.'; 1301 return {success: false, message: message}; 1302 } 1303 1304 if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) 1305 { 1306 message = 'All data values for ' + measure.label + ' are null. Please choose a different measure.'; 1307 return {success: false, message: message}; 1308 } 1309 1310 if (scales[measureName] && scales[measureName].trans == "log") 1311 { 1312 if (invalidLogValues) 1313 { 1314 message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName 1315 + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; 1316 scales[measureName].trans = 'linear'; 1317 } 1318 else if (hasZeroes) 1319 { 1320 message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; 1321 var accFn = aes[measureName]; 1322 aes[measureName] = function(row){return accFn(row) + 1}; 1323 } 1324 } 1325 1326 return {success: true, message: message}; 1327 }; 1328 1329 /** 1330 * Deprecated - use validateAxisMeasure 1331 */ 1332 var validateXAxis = function(chartType, chartConfig, aes, scales, data){ 1333 return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); 1334 }; 1335 /** 1336 * Deprecated - use validateAxisMeasure 1337 */ 1338 var validateYAxis = function(chartType, chartConfig, aes, scales, data){ 1339 return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); 1340 }; 1341 1342 var getMeasureType = function(measure) { 1343 return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; 1344 }; 1345 1346 var isNumericType = function(type) 1347 { 1348 var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; 1349 return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; 1350 }; 1351 1352 var isDateType = function(type) 1353 { 1354 var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; 1355 return t == 'date'; 1356 }; 1357 1358 var _getStudySubjectInfo = function() 1359 { 1360 var studyCtx = LABKEY.getModuleContext("study") || {}; 1361 return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { 1362 tableName: 'Participant', 1363 columnName: 'ParticipantId', 1364 nounPlural: 'Participants', 1365 nounSingular: 'Participant' 1366 }; 1367 }; 1368 1369 var _getStudyTimepointType = function() 1370 { 1371 var studyCtx = LABKEY.getModuleContext("study") || {}; 1372 return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; 1373 }; 1374 1375 var _getMeasureRestrictions = function (chartType, measure) 1376 { 1377 var measureRestrictions = {}; 1378 $.each(getRenderTypes(), function (idx, renderType) 1379 { 1380 if (renderType.name === chartType) 1381 { 1382 $.each(renderType.fields, function (idx2, field) 1383 { 1384 if (field.name === measure) 1385 { 1386 measureRestrictions.numericOnly = field.numericOnly; 1387 measureRestrictions.nonNumericOnly = field.nonNumericOnly; 1388 return false; 1389 } 1390 }); 1391 return false; 1392 } 1393 }); 1394 1395 return measureRestrictions; 1396 }; 1397 1398 /** 1399 * Converts data values passed in to the appropriate type based on measure/dimension information. 1400 * @param chartConfig Chart configuration object 1401 * @param aes Aesthetic mapping functions for each measure/axis 1402 * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) 1403 * @param data The response data from SelectRows 1404 * @returns {{processed: {}, warningMessage: *}} 1405 */ 1406 var doValueConversion = function(chartConfig, aes, renderType, data) 1407 { 1408 var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; 1409 for (var measureName in chartConfig.measures) { 1410 if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { 1411 configMeasure = chartConfig.measures[measureName]; 1412 $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); 1413 1414 var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; 1415 var isXAxis = measureName === 'x' || measureName === 'xSub'; 1416 var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; 1417 var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); 1418 1419 if (configMeasure.measure && !isGroupingMeasure && !isBarYCount 1420 && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { 1421 measuresForProcessing[measureName] = {}; 1422 measuresForProcessing[measureName].name = configMeasure.name; 1423 measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; 1424 measuresForProcessing[measureName].label = configMeasure.label; 1425 configMeasure.normalizedType = 'float'; 1426 configMeasure.type = 'float'; 1427 } 1428 } 1429 } 1430 1431 var response = {processed: {}}; 1432 if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { 1433 response = _processMeasureData(data, aes, measuresForProcessing); 1434 } 1435 1436 //generate error message for dropped values 1437 var warningMessage = ''; 1438 for (var measure in response.droppedValues) { 1439 if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { 1440 warningMessage += " The " 1441 + measure + "-axis measure '" 1442 + response.droppedValues[measure].label + "' had " 1443 + response.droppedValues[measure].numDropped + 1444 " value(s) that could not be converted to a number and are not included in the plot."; 1445 } 1446 } 1447 1448 return {processed: response.processed, warningMessage: warningMessage}; 1449 }; 1450 1451 /** 1452 * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only 1453 * attempt to convert strings to numbers for measures. 1454 * @param rows Data from SelectRows 1455 * @param aes Aesthetic mapping function for the measure/dimensions 1456 * @param measuresForProcessing The measures to be converted, if any 1457 * @returns {{droppedValues: {}, processed: {}}} 1458 */ 1459 var _processMeasureData = function(rows, aes, measuresForProcessing) { 1460 var droppedValues = {}, processedMeasures = {}, dataIsNull; 1461 rows.forEach(function(row) { 1462 //convert measures if applicable 1463 if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { 1464 for (var measure in measuresForProcessing) { 1465 if (measuresForProcessing.hasOwnProperty(measure)) { 1466 dataIsNull = true; 1467 if (!droppedValues[measure]) { 1468 droppedValues[measure] = {}; 1469 droppedValues[measure].label = measuresForProcessing[measure].label; 1470 droppedValues[measure].numDropped = 0; 1471 } 1472 1473 if (aes.hasOwnProperty(measure)) { 1474 var value = aes[measure](row); 1475 if (value !== null) { 1476 dataIsNull = false; 1477 } 1478 row[measuresForProcessing[measure].convertedName] = {value: null}; 1479 if (typeof value !== 'number' && value !== null) { 1480 1481 //only try to convert strings to numbers 1482 if (typeof value === 'string') { 1483 value = value.trim(); 1484 } 1485 else { 1486 //dates, objects, booleans etc. to be assigned value: NULL 1487 value = ''; 1488 } 1489 1490 var n = Number(value); 1491 // empty strings convert to 0, which we must explicitly deny 1492 if (value === '' || isNaN(n)) { 1493 droppedValues[measure].numDropped++; 1494 } 1495 else { 1496 row[measuresForProcessing[measure].convertedName].value = n; 1497 } 1498 } 1499 } 1500 1501 if (!processedMeasures[measure]) { 1502 processedMeasures[measure] = { 1503 converted: false, 1504 convertedName: measuresForProcessing[measure].convertedName, 1505 type: 'float', 1506 normalizedType: 'float' 1507 } 1508 } 1509 1510 processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; 1511 } 1512 } 1513 } 1514 }); 1515 1516 return {droppedValues: droppedValues, processed: processedMeasures}; 1517 }; 1518 1519 /** 1520 * removes all traces of String -> Numeric Conversion from the given chart config 1521 * @param chartConfig 1522 * @returns {updated ChartConfig} 1523 */ 1524 var removeNumericConversionConfig = function(chartConfig) { 1525 if (chartConfig && chartConfig.measures) { 1526 for (var measureName in chartConfig.measures) { 1527 if (chartConfig.measures.hasOwnProperty(measureName)) { 1528 var measure = chartConfig.measures[measureName]; 1529 if (measure && measure.converted && measure.convertedName) { 1530 measure.converted = null; 1531 measure.convertedName = null; 1532 if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { 1533 measure.type = 'string'; 1534 measure.normalizedType = 'string'; 1535 } 1536 } 1537 } 1538 } 1539 } 1540 1541 return chartConfig; 1542 }; 1543 1544 var renderChartSVG = function(renderTo, queryConfig, chartConfig) { 1545 queryConfig.containerPath = LABKEY.container.path; 1546 1547 if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { 1548 var filters = []; 1549 1550 for (var i = 0; i < queryConfig.filterArray.length; i++) { 1551 var f = queryConfig.filterArray[i]; 1552 if (f.hasOwnProperty('getValue')) { 1553 filters.push(f); 1554 } 1555 else { 1556 filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); 1557 } 1558 } 1559 1560 queryConfig.filterArray = filters; 1561 } 1562 1563 queryConfig.success = function(measureStore) { 1564 _renderChartSVG(renderTo, chartConfig, measureStore); 1565 }; 1566 1567 LABKEY.Query.MeasureStore.selectRows(queryConfig); 1568 }; 1569 1570 var _renderChartSVG = function(renderTo, chartConfig, measureStore) { 1571 var responseMetaData = measureStore.getResponseMetadata(); 1572 1573 // explicitly set the chart width/height if not set in the config 1574 if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; 1575 if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; 1576 1577 var xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; 1578 var chartType = getChartType(chartConfig.renderType, xAxisType); 1579 var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); 1580 var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); 1581 if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { 1582 $.extend(true, chartConfig.measures, valueConversionResponse.processed); 1583 aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); 1584 } 1585 var data = measureStore.records(); 1586 if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { 1587 chartConfig.geomOptions.binned = true; 1588 } 1589 var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); 1590 var geom = generateGeom(chartType, chartConfig.geomOptions); 1591 var labels = generateLabels(chartConfig.labels); 1592 1593 if (chartType === 'bar_chart' || chartType === 'pie_chart') { 1594 var dimName = null, subDimName = null; measureName = null, aggType = 'COUNT'; 1595 1596 if (chartConfig.measures.x) { 1597 dimName = chartConfig.measures.x.converted ? chartConfig.measures.x.convertedName : chartConfig.measures.x.name; 1598 } 1599 if (chartConfig.measures.xSub) { 1600 subDimName = chartConfig.measures.xSub.converted ? chartConfig.measures.xSub.convertedName : chartConfig.measures.xSub.name; 1601 } 1602 if (chartConfig.measures.y) { 1603 measureName = chartConfig.measures.y.converted ? chartConfig.measures.y.convertedName : chartConfig.measures.y.name; 1604 1605 if (LABKEY.Utils.isDefined(chartConfig.measures.y.aggregate)) { 1606 aggType = chartConfig.measures.y.aggregate; 1607 aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType; 1608 } 1609 else if (measureName != null) { 1610 aggType = 'SUM'; 1611 } 1612 } 1613 1614 data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false); 1615 } 1616 1617 var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); 1618 _renderMessages(renderTo, validation.messages); 1619 if (!validation.success) 1620 return; 1621 1622 var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data); 1623 $.each(plotConfigArr, function(idx, plotConfig) { 1624 if (chartType === 'pie_chart') { 1625 new LABKEY.vis.PieChart(plotConfig); 1626 } 1627 else { 1628 new LABKEY.vis.Plot(plotConfig).render(); 1629 } 1630 }, this); 1631 }; 1632 1633 var _renderMessages = function(divId, messages) { 1634 if (messages && messages.length > 0) { 1635 var errorDiv = document.createElement('div'); 1636 errorDiv.setAttribute('style', 'padding: 10px; background-color: #ffe5e5; color: #d83f48; font-weight: bold;'); 1637 errorDiv.innerHTML = messages.join('<br/>'); 1638 document.getElementById(divId).appendChild(errorDiv); 1639 } 1640 }; 1641 1642 var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { 1643 var hasNoDataMsg = validateResponseHasData(measureStore, false); 1644 if (hasNoDataMsg != null) 1645 return {success: false, messages: [hasNoDataMsg]}; 1646 1647 var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); 1648 for (var i = 0; i < measureNames.length; i++) { 1649 var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); 1650 for (var j = 0; j < measuresArr.length; j++) { 1651 var measure = measuresArr[j]; 1652 if (LABKEY.Utils.isObject(measure)) { 1653 if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { 1654 return {success: false, messages: ['The measure, ' + measure.label + ', is not available. It may have been renamed or removed.']}; 1655 } 1656 1657 var validation; 1658 if (measureNames[i] === 'y') { 1659 var yAes = {y: getYMeasureAes(measure)}; 1660 validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); 1661 } 1662 else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { 1663 validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); 1664 } 1665 1666 if (LABKEY.Utils.isObject(validation)) { 1667 if (validation.message != null) 1668 messages.push(validation.message); 1669 if (!validation.success) 1670 return {success: false, messages: messages}; 1671 } 1672 } 1673 } 1674 } 1675 1676 return {success: true, messages: messages}; 1677 }; 1678 1679 return { 1680 // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't 1681 // ask me why, I do not know. 1682 /** 1683 * @function 1684 */ 1685 getRenderTypes: getRenderTypes, 1686 getChartType: getChartType, 1687 getSelectedMeasureLabel: getSelectedMeasureLabel, 1688 getTitleFromMeasures: getTitleFromMeasures, 1689 getMeasureType: getMeasureType, 1690 getQueryColumns : getQueryColumns, 1691 getChartTypeBasedWidth : getChartTypeBasedWidth, 1692 getDistinctYAxisSides : getDistinctYAxisSides, 1693 getYMeasureAes : getYMeasureAes, 1694 getDefaultMeasuresLabel: getDefaultMeasuresLabel, 1695 ensureMeasuresAsArray: ensureMeasuresAsArray, 1696 isNumericType: isNumericType, 1697 generateLabels: generateLabels, 1698 generateScales: generateScales, 1699 generateAes: generateAes, 1700 doValueConversion: doValueConversion, 1701 removeNumericConversionConfig: removeNumericConversionConfig, 1702 generateAggregateData: generateAggregateData, 1703 generatePointHover: generatePointHover, 1704 generateBoxplotHover: generateBoxplotHover, 1705 generateDiscreteAcc: generateDiscreteAcc, 1706 generateContinuousAcc: generateContinuousAcc, 1707 generateGroupingAcc: generateGroupingAcc, 1708 generatePointClickFn: generatePointClickFn, 1709 generateGeom: generateGeom, 1710 generateBoxplotGeom: generateBoxplotGeom, 1711 generatePointGeom: generatePointGeom, 1712 generatePlotConfigs: generatePlotConfigs, 1713 generatePlotConfig: generatePlotConfig, 1714 validateResponseHasData: validateResponseHasData, 1715 validateAxisMeasure: validateAxisMeasure, 1716 validateXAxis: validateXAxis, 1717 validateYAxis: validateYAxis, 1718 renderChartSVG: renderChartSVG, 1719 /** 1720 * Loads all of the required dependencies for a Generic Chart. 1721 * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. 1722 * @param {Object} scope The scope to be used when executing the callback. 1723 */ 1724 loadVisDependencies: LABKEY.requiresVisualization 1725 }; 1726 };