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 };