1 /* 2 * Copyright (c) 2013-2019 LabKey Corporation 3 * 4 * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 5 */ 6 if(!LABKEY.vis) { 7 LABKEY.vis = {}; 8 } 9 10 /** 11 * @namespace Namespace used to encapsulate functions related to creating study Time Charts. 12 * Used in the Chart Wizard and when exporting Time Charts as scripts. 13 */ 14 LABKEY.vis.TimeChartHelper = new function() { 15 16 var $ = jQuery; 17 18 /** 19 * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels. 20 * @param {String} mainTitle The label to be used as the main chart title. 21 * @param {String} subtitle The label to be used as the chart subtitle. 22 * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels. 23 * @returns {Object} 24 */ 25 var generateLabels = function(mainTitle, axisArr, subtitle) { 26 var xTitle = '', yLeftTitle = '', yRightTitle = ''; 27 for (var i = 0; i < axisArr.length; i++) 28 { 29 var axis = axisArr[i]; 30 if (axis.name == "y-axis") 31 { 32 if (axis.side == "left") 33 yLeftTitle = axis.label; 34 else 35 yRightTitle = axis.label; 36 } 37 else 38 { 39 xTitle = axis.label; 40 } 41 } 42 43 return { 44 main : { value : mainTitle }, 45 subtitle : { value : subtitle, color: '#404040' }, 46 x : { value : xTitle }, 47 yLeft : { value : yLeftTitle }, 48 yRight : { value : yRightTitle } 49 }; 50 }; 51 52 /** 53 * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. 54 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 55 * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap. 56 * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks. 57 * @returns {Object} 58 */ 59 var generateScales = function(config, tickMap, numberFormats) { 60 if (config.measures.length == 0) 61 throw "There must be at least one specified measure in the chartInfo config!"; 62 63 var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText, 64 yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat, 65 yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat, 66 valExponentialDigits = 6; 67 68 for (var i = 0; i < config.axis.length; i++) 69 { 70 var axis = config.axis[i]; 71 if (axis.name == "y-axis") 72 { 73 if (axis.side == "left") 74 { 75 yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); 76 yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); 77 yLeftTrans = axis.scale ? axis.scale : "linear"; 78 yLeftTickFormat = function(value) { 79 if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { 80 return value.toExponential(); 81 } 82 else if (LABKEY.Utils.isFunction(numberFormats.left)) { 83 return numberFormats.left(value); 84 } 85 return value; 86 } 87 } 88 else 89 { 90 yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); 91 yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); 92 yRightTrans = axis.scale ? axis.scale : "linear"; 93 yRightTickFormat = function(value) { 94 if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { 95 return value.toExponential(); 96 } 97 else if (LABKEY.Utils.isFunction(numberFormats.right)) { 98 return numberFormats.right(value); 99 } 100 return value; 101 } 102 } 103 } 104 else 105 { 106 xMin = typeof axis.range.min == "number" ? axis.range.min : null; 107 xMax = typeof axis.range.max == "number" ? axis.range.max : null; 108 xTrans = axis.scale ? axis.scale : "linear"; 109 } 110 } 111 112 if (config.measures[0].time != "date") 113 { 114 xTickFormat = function(value) { 115 return tickMap[value] ? tickMap[value].label : ""; 116 }; 117 118 xTickHoverText = function(value) { 119 return tickMap[value] ? tickMap[value].description : ""; 120 }; 121 } 122 // Issue 27309: Don't show decimal values on x-axis for date-based time charts with interval = "Days" 123 else if (config.measures[0].time == 'date' && config.measures[0].dateOptions.interval == 'Days') 124 { 125 xTickFormat = function(value) { 126 return LABKEY.Utils.isNumber(value) && value % 1 != 0 ? null : value; 127 }; 128 } 129 130 return { 131 x: { 132 scaleType : 'continuous', 133 trans : xTrans, 134 domain : [xMin, xMax], 135 tickFormat : xTickFormat ? xTickFormat : null, 136 tickHoverText : xTickHoverText ? xTickHoverText : null 137 }, 138 yLeft: { 139 scaleType : 'continuous', 140 trans : yLeftTrans, 141 domain : [yLeftMin, yLeftMax], 142 tickFormat : yLeftTickFormat ? yLeftTickFormat : null 143 }, 144 yRight: { 145 scaleType : 'continuous', 146 trans : yRightTrans, 147 domain : [yRightMin, yRightMax], 148 tickFormat : yRightTickFormat ? yRightTickFormat : null 149 }, 150 shape: { 151 scaleType : 'discrete' 152 } 153 }; 154 }; 155 156 /** 157 * Generate the x-axis interval column alias key. For date based charts, this will be a time interval (i.e. Days, Weeks, etc.) 158 * and for visit based charts, this will be the column alias for the visit field. 159 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 160 * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. 161 * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. 162 * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). 163 * @returns {String} 164 */ 165 var generateIntervalKey = function(config, individualColumnAliases, aggregateColumnAliases, nounSingular) 166 { 167 nounSingular = nounSingular || getStudySubjectInfo().nounSingular; 168 169 if (config.measures.length == 0) 170 throw "There must be at least one specified measure in the chartInfo config!"; 171 if (!individualColumnAliases && !aggregateColumnAliases) 172 throw "We expect to either be displaying individual series lines or aggregate data!"; 173 174 if (config.measures[0].time == "date") 175 { 176 return config.measures[0].dateOptions.interval; 177 } 178 else 179 { 180 return individualColumnAliases ? 181 LABKEY.vis.getColumnAlias(individualColumnAliases, nounSingular + "Visit/Visit") : 182 LABKEY.vis.getColumnAlias(aggregateColumnAliases, nounSingular + "Visit/Visit"); 183 } 184 }; 185 186 /** 187 * Generate that x-axis tick mark mapping for a visit based chart. 188 * @param {Object} visitMap For visit based charts, the study visit information map. 189 * @returns {Object} 190 */ 191 var generateTickMap = function(visitMap) { 192 var tickMap = {}; 193 for (var rowId in visitMap) 194 { 195 if (visitMap.hasOwnProperty(rowId)) 196 { 197 tickMap[visitMap[rowId].displayOrder] = { 198 label: visitMap[rowId].displayName, 199 description: visitMap[rowId].description || visitMap[rowId].displayName 200 }; 201 } 202 } 203 204 return tickMap; 205 }; 206 207 /** 208 * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} 209 * and {@link LABKEY.vis.Layer}. 210 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 211 * @param {Object} visitMap For visit based charts, the study visit information map. 212 * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. 213 * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. 214 * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). 215 * @returns {Object} 216 */ 217 var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName) 218 { 219 nounColumnName = nounColumnName || getStudySubjectInfo().columnName; 220 221 if (config.measures.length == 0) 222 throw "There must be at least one specified measure in the chartInfo config!"; 223 224 var xAes; 225 if (config.measures[0].time == "date") { 226 xAes = function(row) { 227 return _getRowValue(row, intervalKey); 228 }; 229 } 230 else { 231 xAes = function(row) { 232 return visitMap[_getRowValue(row, intervalKey, 'value')].displayOrder; 233 }; 234 } 235 236 var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; 237 238 return { 239 x: xAes, 240 color: function(row) { 241 return _getRowValue(row, individualSubjectColumn); 242 }, 243 group: function(row) { 244 return _getRowValue(row, individualSubjectColumn); 245 }, 246 shape: function(row) { 247 return _getRowValue(row, individualSubjectColumn); 248 }, 249 pathColor: function(rows) { 250 return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], individualSubjectColumn) : null; 251 } 252 }; 253 }; 254 255 /** 256 * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list. 257 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 258 * @param {Object} visitMap For visit based charts, the study visit information map. 259 * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. 260 * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. 261 * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData. 262 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 263 * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. 264 * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). 265 * @returns {Array} 266 */ 267 var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName) 268 { 269 nounColumnName = nounColumnName || getStudySubjectInfo().columnName; 270 271 if (config.measures.length == 0) 272 throw "There must be at least one specified measure in the chartInfo config!"; 273 if (!individualColumnAliases && !aggregateColumnAliases) 274 throw "We expect to either be displaying individual series lines or aggregate data!"; 275 276 var layers = []; 277 var isDateBased = config.measures[0].time == "date"; 278 var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; 279 var aggregateSubjectColumn = "UniqueId"; 280 281 var generateLayerAes = function(name, yAxisSide, columnName){ 282 var yName = yAxisSide == "left" ? "yLeft" : "yRight"; 283 var aes = {}; 284 aes[yName] = function(row) { 285 // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. 286 return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; 287 }; 288 return aes; 289 }; 290 291 var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){ 292 var yName = yAxisSide == "left" ? "yLeft" : "yRight"; 293 var aes = {}; 294 aes[yName] = function(row) { 295 // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. 296 return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; 297 }; 298 aes.group = aes.color = aes.shape = function(row) { 299 return _getRowValue(row, subjectColumn); 300 }; 301 aes.pathColor = function(rows) { 302 return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], subjectColumn) : null; 303 }; 304 aes.error = function(row) { 305 return row[errorColumn] ? _getRowValue(row, errorColumn) : null; 306 }; 307 return aes; 308 }; 309 310 var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){ 311 if (visitMap) 312 { 313 if (errorColumn) 314 { 315 return function(row){ 316 var subject = _getRowValue(row, subjectColumn); 317 var errorVal = _getRowValue(row, errorColumn) || 'n/a'; 318 return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + 319 ',\n ' + name + ': ' + _getRowValue(row, columnName) + 320 ',\n ' + errorType + ': ' + errorVal; 321 } 322 } 323 else 324 { 325 return function(row){ 326 var subject = _getRowValue(row, subjectColumn); 327 return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + 328 ',\n ' + name + ': ' + _getRowValue(row, columnName); 329 }; 330 } 331 } 332 else 333 { 334 if (errorColumn) 335 { 336 return function(row){ 337 var subject = _getRowValue(row, subjectColumn); 338 var errorVal = _getRowValue(row, errorColumn) || 'n/a'; 339 return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + 340 ',\n ' + name + ': ' + _getRowValue(row, columnName) + 341 ',\n ' + errorType + ': ' + errorVal; 342 }; 343 } 344 else 345 { 346 return function(row){ 347 var subject = _getRowValue(row, subjectColumn); 348 return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + 349 ',\n ' + name + ': ' + _getRowValue(row, columnName); 350 }; 351 } 352 } 353 }; 354 355 // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique) 356 // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique) 357 var useUniqueSeriesNames = false; 358 var uniqueChartSeriesNames = []; 359 for (var i = 0; i < seriesList.length; i++) 360 { 361 if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1) 362 { 363 useUniqueSeriesNames = true; 364 break; 365 } 366 uniqueChartSeriesNames.push(seriesList[i].name); 367 } 368 369 for (var i = seriesList.length -1; i >= 0; i--) 370 { 371 var chartSeries = seriesList[i]; 372 373 var chartSeriesName = chartSeries.label; 374 if (useUniqueSeriesNames) 375 { 376 if (chartSeries.aliasLookupInfo.pivotValue) 377 chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue; 378 else 379 chartSeriesName = chartSeries.aliasLookupInfo.alias; 380 } 381 382 var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo); 383 if (individualColumnAliases) 384 { 385 if (!config.hideTrendLine) { 386 var pathLayerConfig = { 387 geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), 388 aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) 389 }; 390 391 if (seriesList.length > 1) 392 pathLayerConfig.name = chartSeriesName; 393 394 layers.push(new LABKEY.vis.Layer(pathLayerConfig)); 395 } 396 397 if (!config.hideDataPoints) 398 { 399 var pointLayerConfig = { 400 geom: new LABKEY.vis.Geom.Point(), 401 aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) 402 }; 403 404 if (seriesList.length > 1) 405 pointLayerConfig.name = chartSeriesName; 406 407 if (isDateBased) 408 pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null); 409 else 410 pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null); 411 412 if (config.pointClickFn) 413 { 414 pointLayerConfig.aes.pointClickFn = generatePointClickFn( 415 config.pointClickFn, 416 {participant: individualSubjectColumn, interval: intervalKey, measure: columnName}, 417 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} 418 ); 419 } 420 421 layers.push(new LABKEY.vis.Layer(pointLayerConfig)); 422 } 423 } 424 425 if (aggregateData && aggregateColumnAliases) 426 { 427 var errorBarType = null; 428 if (config.errorBars == 'SD') 429 errorBarType = '_STDDEV'; 430 else if (config.errorBars == 'SEM') 431 errorBarType = '_STDERR'; 432 433 var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null; 434 435 if (!config.hideTrendLine) { 436 var aggregatePathLayerConfig = { 437 data: aggregateData, 438 geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), 439 aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) 440 }; 441 442 if (seriesList.length > 1) 443 aggregatePathLayerConfig.name = chartSeriesName; 444 445 layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig)); 446 } 447 448 if (errorColumnName) 449 { 450 var aggregateErrorLayerConfig = { 451 data: aggregateData, 452 geom: new LABKEY.vis.Geom.ErrorBar(), 453 aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) 454 }; 455 456 if (seriesList.length > 1) 457 aggregateErrorLayerConfig.name = chartSeriesName; 458 459 layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig)); 460 } 461 462 if (!config.hideDataPoints) 463 { 464 var aggregatePointLayerConfig = { 465 data: aggregateData, 466 geom: new LABKEY.vis.Geom.Point(), 467 aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) 468 }; 469 470 if (seriesList.length > 1) 471 aggregatePointLayerConfig.name = chartSeriesName; 472 473 if (isDateBased) 474 aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars) 475 else 476 aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars); 477 478 if (config.pointClickFn) 479 { 480 aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn( 481 config.pointClickFn, 482 {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName}, 483 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} 484 ); 485 } 486 487 layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig)); 488 } 489 } 490 } 491 492 return layers; 493 }; 494 495 // private function 496 var generatePointClickFn = function(fnString, columnMap, measureInfo){ 497 // the developer is expected to return a function, so we encapalate it within the anonymous function 498 // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel) 499 500 // using new Function is quicker than eval(), even in IE. 501 var pointClickFn = new Function('return ' + fnString)(); 502 return function(clickEvent, data) { 503 pointClickFn(data, columnMap, measureInfo, clickEvent); 504 }; 505 }; 506 507 /** 508 * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and 509 * dimension that is selected in the chart. 510 * @param {Array} measures The array of selected measures from the chart config. 511 * @returns {Array} 512 */ 513 var generateSeriesList = function(measures) { 514 var arr = []; 515 for (var i = 0; i < measures.length; i++) 516 { 517 var md = measures[i]; 518 519 if (md.dimension && md.dimension.values) 520 { 521 Ext4.each(md.dimension.values, function(val) { 522 arr.push({ 523 schemaName: md.dimension.schemaName, 524 queryName: md.dimension.queryName, 525 name: val, 526 label: val, 527 measureIndex: i, 528 yAxisSide: md.measure.yAxis, 529 aliasLookupInfo: {measureName: md.measure.name, pivotValue: val} 530 }); 531 }); 532 } 533 else 534 { 535 arr.push({ 536 schemaName: md.measure.schemaName, 537 queryName: md.measure.queryName, 538 name: md.measure.name, 539 label: md.measure.label, 540 measureIndex: i, 541 yAxisSide: md.measure.yAxis, 542 aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name} 543 }); 544 } 545 } 546 return arr; 547 }; 548 549 // private function 550 var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular) 551 { 552 nounSingular = nounSingular || getStudySubjectInfo().nounSingular; 553 var hasDateCol = firstMeasure.dateOptions && firstMeasure.dateOptions.dateCol; 554 555 return [ 556 subject, 557 { 558 schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, 559 queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, 560 name : isDateBased && hasDateCol ? firstMeasure.dateOptions.dateCol.name : getSubjectVisitColName(nounSingular, 'DisplayOrder') 561 }, 562 { 563 schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, 564 queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, 565 name : (isDateBased ? nounSingular + "Visit/Visit" : getSubjectVisitColName(nounSingular, 'SequenceNumMin')) 566 } 567 ]; 568 }; 569 570 var getSubjectVisitColName = function(nounSingular, suffix) 571 { 572 var nounSingular = nounSingular || getStudySubjectInfo().nounSingular; 573 return nounSingular + 'Visit/Visit/' + suffix; 574 }; 575 576 /** 577 * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges. 578 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 579 * @returns {boolean} 580 */ 581 var generateApplyClipRect = function(config) { 582 var xAxisIndex = getAxisIndex(config.axis, "x-axis"); 583 var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); 584 var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); 585 586 return ( 587 xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) || 588 leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) || 589 rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null) 590 ); 591 }; 592 593 /** 594 * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple 595 * charts that are set to use the same axis ranges across all charts in the report. 596 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 597 * @param {Object} data The data object, from getChartData. 598 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 599 * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). 600 */ 601 var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular) 602 { 603 nounSingular = nounSingular || getStudySubjectInfo().nounSingular; 604 605 if (config.measures.length == 0) 606 throw "There must be at least one specified measure in the chartInfo config!"; 607 if (!data.individual && !data.aggregate) 608 throw "We expect to either be displaying individual series lines or aggregate data!"; 609 610 var rows = []; 611 if (LABKEY.Utils.isDefined(data.individual)) { 612 rows = data.individual.measureStore.records(); 613 } 614 else if (LABKEY.Utils.isDefined(data.aggregate)) { 615 rows = data.aggregate.measureStore.records(); 616 } 617 618 config.hasNoData = rows.length == 0; 619 620 // In multi-chart case, we need to pre-compute the default axis ranges so that all charts share them 621 // (if 'automatic across charts' is selected for the given axis) 622 if (config.chartLayout != "single") 623 { 624 var leftMeasures = [], 625 rightMeasures = [], 626 xName, xFunc, 627 min, max, tempMin, tempMax, errorBarType, 628 leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor, 629 columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null), 630 isDateBased = config.measures[0].time == "date", 631 xAxisIndex = getAxisIndex(config.axis, "x-axis"), 632 leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"), 633 rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); 634 635 for (var i = 0; i < seriesList.length; i++) 636 { 637 var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo); 638 if (seriesList[i].yAxisSide == "left") 639 leftMeasures.push(columnName); 640 else if (seriesList[i].yAxisSide == "right") 641 rightMeasures.push(columnName); 642 } 643 644 if (isDateBased) 645 { 646 xName = config.measures[0].dateOptions.interval; 647 xFunc = function(row){ 648 return _getRowValue(row, xName); 649 }; 650 } 651 else 652 { 653 var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap; 654 xName = LABKEY.vis.getColumnAlias(columnAliases, nounSingular + "Visit/Visit"); 655 xFunc = function(row){ 656 return visitMap[_getRowValue(row, xName, 'value')].displayOrder; 657 }; 658 } 659 660 if (config.axis[xAxisIndex].range.type != 'automatic_per_chart') 661 { 662 if (config.axis[xAxisIndex].range.min == null) 663 config.axis[xAxisIndex].range.min = d3.min(rows, xFunc); 664 665 if (config.axis[xAxisIndex].range.max == null) 666 config.axis[xAxisIndex].range.max = d3.max(rows, xFunc); 667 } 668 669 if (config.errorBars !== 'None') 670 errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR'; 671 672 if (leftAxisIndex > -1) 673 { 674 // If we have a left axis then we need to find the min/max 675 min = null; max = null; tempMin = null; tempMax = null; 676 leftAccessor = function(row) { 677 return _getRowValue(row, leftMeasures[i]); 678 }; 679 680 if (errorBarType) 681 { 682 // If we have error bars we need to calculate min/max with the error values in mind. 683 leftAccessorMin = function(row) { 684 if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) 685 { 686 var error = _getRowValue(row, leftMeasures[i] + errorBarType); 687 return _getRowValue(row, leftMeasures[i]) - error; 688 } 689 else 690 return null; 691 }; 692 693 leftAccessorMax = function(row) { 694 if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) 695 { 696 var error = _getRowValue(row, leftMeasures[i] + errorBarType); 697 return _getRowValue(row, leftMeasures[i]) + error; 698 } 699 else 700 return null; 701 }; 702 } 703 704 if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart') 705 { 706 if (config.axis[leftAxisIndex].range.min == null) 707 { 708 for (var i = 0; i < leftMeasures.length; i++) 709 { 710 tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor); 711 min = min == null ? tempMin : tempMin < min ? tempMin : min; 712 } 713 config.axis[leftAxisIndex].range.min = min; 714 } 715 716 if (config.axis[leftAxisIndex].range.max == null) 717 { 718 for (var i = 0; i < leftMeasures.length; i++) 719 { 720 tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor); 721 max = max == null ? tempMax : tempMax > max ? tempMax : max; 722 } 723 config.axis[leftAxisIndex].range.max = max; 724 } 725 } 726 } 727 728 if (rightAxisIndex > -1) 729 { 730 // If we have a right axis then we need to find the min/max 731 min = null; max = null; tempMin = null; tempMax = null; 732 rightAccessor = function(row){ 733 return _getRowValue(row, rightMeasures[i]); 734 }; 735 736 if (errorBarType) 737 { 738 rightAccessorMin = function(row) { 739 if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) 740 { 741 var error = _getRowValue(row, rightMeasures[i] + errorBarType); 742 return _getRowValue(row, rightMeasures[i]) - error; 743 } 744 else 745 return null; 746 }; 747 748 rightAccessorMax = function(row) { 749 if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) 750 { 751 var error = _getRowValue(row, rightMeasures[i] + errorBarType); 752 return _getRowValue(row, rightMeasures[i]) + error; 753 } 754 else 755 return null; 756 }; 757 } 758 759 if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart') 760 { 761 if (config.axis[rightAxisIndex].range.min == null) 762 { 763 for (var i = 0; i < rightMeasures.length; i++) 764 { 765 tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor); 766 min = min == null ? tempMin : tempMin < min ? tempMin : min; 767 } 768 config.axis[rightAxisIndex].range.min = min; 769 } 770 771 if (config.axis[rightAxisIndex].range.max == null) 772 { 773 for (var i = 0; i < rightMeasures.length; i++) 774 { 775 tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor); 776 max = max == null ? tempMax : tempMax > max ? tempMax : max; 777 } 778 config.axis[rightAxisIndex].range.max = max; 779 } 780 } 781 } 782 } 783 }; 784 785 /** 786 * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report. 787 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 788 * @param {Object} data The data object, from getChartData. 789 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 790 * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect. 791 * @param {int} maxCharts The maximum number of charts to display in one report. 792 * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). 793 * @returns {Array} 794 */ 795 var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) { 796 var plotConfigInfoArr = [], 797 subjectColumnName = null; 798 799 nounColumnName = nounColumnName || getStudySubjectInfo().columnName; 800 if (data.individual) 801 subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName); 802 803 var generateGroupSeries = function(rows, groups, subjectColumn) { 804 // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array 805 // groups is config.subject.groups 806 var dataByGroup = {}; 807 808 for (var i = 0; i < rows.length; i++) 809 { 810 var rowSubject = _getRowValue(rows[i], subjectColumn); 811 for (var j = 0; j < groups.length; j++) 812 { 813 if (groups[j].participantIds.indexOf(rowSubject) > -1) 814 { 815 if (!dataByGroup[groups[j].label]) 816 dataByGroup[groups[j].label] = []; 817 818 dataByGroup[groups[j].label].push(rows[i]); 819 } 820 } 821 } 822 823 return dataByGroup; 824 }; 825 826 // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension 827 if (config.chartLayout == "per_subject") 828 { 829 var groupAccessor = function(row) { 830 return _getRowValue(row, subjectColumnName); 831 }; 832 833 var dataPerParticipant = getDataWithSeriesCheck(data.individual.measureStore.records(), groupAccessor, seriesList, data.individual.columnAliases); 834 for (var participant in dataPerParticipant) 835 { 836 if (dataPerParticipant.hasOwnProperty(participant)) 837 { 838 // skip the group if there is no data for it 839 if (!dataPerParticipant[participant].hasSeriesData) 840 continue; 841 842 plotConfigInfoArr.push({ 843 title: config.title ? config.title : participant, 844 subtitle: config.title ? participant : undefined, 845 series: seriesList, 846 individualData: dataPerParticipant[participant].data, 847 applyClipRect: applyClipRect 848 }); 849 850 if (plotConfigInfoArr.length >= maxCharts) 851 break; 852 } 853 } 854 } 855 else if (config.chartLayout == "per_group") 856 { 857 var groupedIndividualData = null, groupedAggregateData = null; 858 859 //Display individual lines 860 if (data.individual) { 861 groupedIndividualData = generateGroupSeries(data.individual.measureStore.records(), config.subject.groups, subjectColumnName); 862 } 863 864 // Display aggregate lines 865 if (data.aggregate) { 866 var groupAccessor = function(row) { 867 return _getRowValue(row, 'UniqueId'); 868 }; 869 870 groupedAggregateData = getDataWithSeriesCheck(data.aggregate.measureStore.records(), groupAccessor, seriesList, data.aggregate.columnAliases); 871 } 872 873 for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++) 874 { 875 var group = config.subject.groups[i]; 876 877 // skip the group if there is no data for it 878 if ((groupedIndividualData != null && !groupedIndividualData[group.label]) 879 || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData))) 880 { 881 continue; 882 } 883 884 plotConfigInfoArr.push({ 885 title: config.title ? config.title : group.label, 886 subtitle: config.title ? group.label : undefined, 887 series: seriesList, 888 individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null, 889 aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null, 890 applyClipRect: applyClipRect 891 }); 892 893 if (plotConfigInfoArr.length > maxCharts) 894 break; 895 } 896 } 897 else if (config.chartLayout == "per_dimension") 898 { 899 for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++) 900 { 901 // skip the measure/dimension if there is no data for it 902 if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name]) 903 || (data.individual && !data.individual.hasData[seriesList[i].name])) 904 { 905 continue; 906 } 907 908 plotConfigInfoArr.push({ 909 title: config.title ? config.title : seriesList[i].label, 910 subtitle: config.title ? seriesList[i].label : undefined, 911 series: [seriesList[i]], 912 individualData: data.individual ? data.individual.measureStore.records() : null, 913 aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, 914 applyClipRect: applyClipRect 915 }); 916 917 if (plotConfigInfoArr.length > maxCharts) 918 break; 919 } 920 } 921 else if (config.chartLayout == "single") 922 { 923 //Single Line Chart, with all participants or groups. 924 plotConfigInfoArr.push({ 925 title: config.title, 926 series: seriesList, 927 individualData: data.individual ? data.individual.measureStore.records() : null, 928 aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, 929 height: 610, 930 style: null, 931 applyClipRect: applyClipRect 932 }); 933 } 934 935 return plotConfigInfoArr; 936 }; 937 938 // private function 939 var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) { 940 /* 941 Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor. 942 Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData 943 */ 944 var groupedData = {}; 945 for (var i = 0; i < data.length; i++) 946 { 947 var value = groupAccessor(data[i]); 948 if (!groupedData[value]) 949 { 950 groupedData[value] = {data: [], hasSeriesData: false}; 951 } 952 groupedData[value].data.push(data[i]); 953 954 for (var j = 0; j < seriesList.length; j++) 955 { 956 var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo); 957 if (seriesAlias && _getRowValue(data[i], seriesAlias) != null) 958 { 959 groupedData[value].hasSeriesData = true; 960 break; 961 } 962 } 963 } 964 return groupedData; 965 }; 966 967 /** 968 * Get the index in the axes array for a given axis (ie left y-axis). 969 * @param {Array} axes The array of specified axis information for this chart. 970 * @param {String} axisName The chart axis (i.e. x-axis or y-axis). 971 * @param {String} [side] The y-axis side (i.e. left or right). 972 * @returns {number} 973 */ 974 var getAxisIndex = function(axes, axisName, side) { 975 var index = -1; 976 for (var i = 0; i < axes.length; i++) 977 { 978 if (!side && axes[i].name == axisName) 979 { 980 index = i; 981 break; 982 } 983 else if (axes[i].name == axisName && axes[i].side == side) 984 { 985 index = i; 986 break; 987 } 988 } 989 return index; 990 }; 991 992 /** 993 * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the 994 * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data. 995 * Calls the success callback function in the config when it has received all of the requested data. 996 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 997 */ 998 var getChartData = function(config) { 999 if (!config.success) 1000 throw "You must specify a success callback function!"; 1001 if (!config.failure) 1002 throw "You must specify a failure callback function!"; 1003 if (!config.chartInfo) 1004 throw "You must specify a chartInfo config!"; 1005 if (config.chartInfo.measures.length == 0) 1006 throw "There must be at least one specified measure in the chartInfo config!"; 1007 if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate) 1008 throw "We expect to either be displaying individual series lines or aggregate data!"; 1009 1010 // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects 1011 var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0; 1012 if (config.chartInfo.displayIndividual && subjectLength > 10000) 1013 { 1014 config.chartInfo.displayIndividual = false; 1015 config.chartInfo.subject.values = undefined; 1016 } 1017 1018 var chartData = {numberFormats: {}}; 1019 var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1; 1020 var isDateBased = config.chartInfo.measures[0].time == "date"; 1021 var seriesList = generateSeriesList(config.chartInfo.measures); 1022 1023 // get the visit map info for those visits in the response data 1024 var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) { 1025 var trimmedVisits = []; 1026 for (var v in origVisitMap) { 1027 if (origVisitMap.hasOwnProperty(v)) { 1028 if (visitsInDataArr.indexOf(parseInt(v)) != -1) { 1029 trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v])); 1030 } 1031 } 1032 } 1033 // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1 1034 trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder}); 1035 var newVisitMap = {}; 1036 for (var i = 0; i < trimmedVisits.length; i++) 1037 { 1038 trimmedVisits[i].displayOrder = i + 1; 1039 newVisitMap[trimmedVisits[i].id] = trimmedVisits[i]; 1040 } 1041 1042 return newVisitMap; 1043 }; 1044 1045 var successCallback = function(response, dataType) { 1046 // check for success=false 1047 if (LABKEY.Utils.isDefined(response.success) && LABKEY.Utils.isBoolean(response.success) && !response.success) 1048 { 1049 config.failure.call(config.scope, response); 1050 return; 1051 } 1052 1053 // Issue 16156: for date based charts, give error message if there are no calculated interval values 1054 if (isDateBased) { 1055 var intervalAlias = config.chartInfo.measures[0].dateOptions.interval; 1056 var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(intervalAlias)); 1057 chartData.hasIntervalData = uniqueNonNullValues.length > 0; 1058 } 1059 else { 1060 chartData.hasIntervalData = true; 1061 } 1062 1063 // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response 1064 // also keep track of which measure/dimensions have negative values (for log scale) 1065 response.hasData = {}; 1066 response.hasNegativeValues = {}; 1067 Ext4.each(seriesList, function(s) { 1068 var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo); 1069 var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(alias)); 1070 1071 response.hasData[s.name] = uniqueNonNullValues.length > 0; 1072 response.hasNegativeValues[s.name] = Ext4.Array.min(uniqueNonNullValues) < 0; 1073 }); 1074 1075 // trim the visit map domain to just those visits in the response data 1076 if (!isDateBased) { 1077 var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular; 1078 var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, nounSingular + "Visit/Visit"); 1079 var visitsInData = response.measureStore.members(visitMappedName); 1080 response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData); 1081 } 1082 else { 1083 response.visitMap = {}; 1084 } 1085 1086 chartData[dataType] = response; 1087 1088 generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat); 1089 1090 // if we have all request data back, return the result 1091 counter--; 1092 if (counter == 0) 1093 config.success.call(config.scope, chartData); 1094 }; 1095 1096 var getSelectRowsSort = function(response, dataType) 1097 { 1098 var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular, 1099 sort = dataType == 'aggregate' ? 'GroupingOrder,UniqueId' : response.measureToColumn[config.chartInfo.subject.name]; 1100 1101 if (isDateBased) 1102 { 1103 sort += ',' + config.chartInfo.measures[0].dateOptions.interval; 1104 } 1105 else 1106 { 1107 // Issue 28529: if we have a SubjectVisit/sequencenum column, use that instead of SubjectVisit/Visit/SequenceNumMin 1108 var sequenceNumCol = response.measureToColumn[nounSingular + 'Visit/sequencenum']; 1109 if (!LABKEY.Utils.isDefined(sequenceNumCol)) 1110 sequenceNumCol = response.measureToColumn[getSubjectVisitColName(nounSingular, 'SequenceNumMin')]; 1111 1112 sort += ',' + response.measureToColumn[getSubjectVisitColName(nounSingular, 'DisplayOrder')] + ',' + sequenceNumCol; 1113 } 1114 1115 return sort; 1116 }; 1117 1118 var queryTempResultsForRows = function(response, dataType) 1119 { 1120 // Issue 28529: re-query for the actual data off of the temp query results 1121 LABKEY.Query.MeasureStore.selectRows({ 1122 containerPath: config.containerPath, 1123 schemaName: response.schemaName, 1124 queryName: response.queryName, 1125 requiredVersion : 13.2, 1126 maxRows: -1, 1127 sort: getSelectRowsSort(response, dataType), 1128 success: function(measureStore) { 1129 response.measureStore = measureStore; 1130 successCallback(response, dataType); 1131 } 1132 }); 1133 }; 1134 1135 if (config.chartInfo.displayIndividual) 1136 { 1137 //Get data for individual lines. 1138 LABKEY.Query.Visualization.getData({ 1139 metaDataOnly: true, 1140 containerPath: config.containerPath, 1141 success: function(response) { 1142 queryTempResultsForRows(response, "individual"); 1143 }, 1144 failure : function(info, response, options) { 1145 config.failure.call(config.scope, info, Ext4.JSON.decode(response.responseText)); 1146 }, 1147 measures: config.chartInfo.measures, 1148 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), 1149 limit : config.dataLimit || 10000, 1150 parameters : config.chartInfo.parameters, 1151 filterUrl: config.chartInfo.filterUrl, 1152 filterQuery: config.chartInfo.filterQuery 1153 }); 1154 } 1155 1156 if (config.chartInfo.displayAggregate) 1157 { 1158 //Get data for Aggregates lines. 1159 var groups = []; 1160 for (var i = 0; i < config.chartInfo.subject.groups.length; i++) 1161 { 1162 var group = config.chartInfo.subject.groups[i]; 1163 // encode the group id & type, so we can distinguish between cohort and participant group in the union table 1164 groups.push(group.id + '-' + group.type); 1165 } 1166 1167 LABKEY.Query.Visualization.getData({ 1168 metaDataOnly: true, 1169 containerPath: config.containerPath, 1170 success: function(response) { 1171 queryTempResultsForRows(response, "aggregate"); 1172 }, 1173 failure : function(info) { 1174 config.failure.call(config.scope, info); 1175 }, 1176 measures: config.chartInfo.measures, 1177 groupBys: [ 1178 // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first 1179 {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]}, 1180 {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups} 1181 ], 1182 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), 1183 limit : config.dataLimit || 10000, 1184 parameters : config.chartInfo.parameters, 1185 filterUrl: config.chartInfo.filterUrl, 1186 filterQuery: config.chartInfo.filterQuery 1187 }); 1188 } 1189 }; 1190 1191 /** 1192 * Get the set of measures from the tables/queries in the study schema. 1193 * @param successCallback 1194 * @param callbackScope 1195 */ 1196 var getStudyMeasures = function(successCallback, callbackScope) 1197 { 1198 if (getStudyTimepointType() != null) 1199 { 1200 LABKEY.Query.Visualization.getMeasures({ 1201 filters: ['study|~'], 1202 dateMeasures: false, 1203 success: function (measures, response) 1204 { 1205 var o = Ext4.JSON.decode(response.responseText); 1206 successCallback.call(callbackScope, o.measures); 1207 }, 1208 failure: this.onFailure, 1209 scope: this 1210 }); 1211 } 1212 else 1213 { 1214 successCallback.call(callbackScope, []); 1215 } 1216 }; 1217 1218 /** 1219 * If this is a container with a configured study, get the timepoint type from the study module context. 1220 * @returns {String|null} 1221 */ 1222 var getStudyTimepointType = function() 1223 { 1224 var studyCtx = LABKEY.getModuleContext("study") || {}; 1225 return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; 1226 }; 1227 1228 /** 1229 * Generate the number format functions for the left and right y-axis and attach them to the chart data object 1230 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 1231 * @param {Object} data The data object, from getChartData. 1232 * @param {Object} defaultNumberFormat 1233 */ 1234 var generateNumberFormats = function(config, data, defaultNumberFormat) { 1235 var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields; 1236 1237 for (var i = 0; i < config.axis.length; i++) 1238 { 1239 var axis = config.axis[i]; 1240 if (axis.side) 1241 { 1242 // Find the first measure with the matching side that has a numberFormat. 1243 for (var j = 0; j < config.measures.length; j++) 1244 { 1245 var measure = config.measures[j].measure; 1246 1247 if (data.numberFormats[axis.side]) 1248 break; 1249 1250 if (measure.yAxis == axis.side) 1251 { 1252 var metaDataName = measure.alias; 1253 for (var k = 0; k < fields.length; k++) 1254 { 1255 var field = fields[k]; 1256 if (field.name == metaDataName) 1257 { 1258 if (field.extFormatFn) 1259 { 1260 data.numberFormats[axis.side] = eval(field.extFormatFn); 1261 break; 1262 } 1263 } 1264 } 1265 } 1266 } 1267 1268 if (!data.numberFormats[axis.side]) 1269 { 1270 // If after all the searching we still don't have a numberformat use the default number format. 1271 data.numberFormats[axis.side] = defaultNumberFormat; 1272 } 1273 } 1274 } 1275 }; 1276 1277 /** 1278 * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc. 1279 * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter 1280 * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered. 1281 * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning. 1282 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 1283 * @returns {Object} 1284 */ 1285 var validateChartConfig = function(config) { 1286 var message = ""; 1287 1288 if (!config.measures || config.measures.length == 0) 1289 { 1290 message = "No measure selected. Please select at lease one measure."; 1291 return {success: false, message: message}; 1292 } 1293 1294 if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1) 1295 { 1296 message = "Could not find x-axis in chart measure information."; 1297 return {success: false, message: message}; 1298 } 1299 1300 if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0) 1301 { 1302 var nounSingular = getStudySubjectInfo().nounSingular; 1303 message = "No " + nounSingular.toLowerCase() + " selected. " + 1304 "Please select at least one " + nounSingular.toLowerCase() + "."; 1305 return {success: false, message: message}; 1306 } 1307 1308 if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1) 1309 { 1310 message = "No group selected. Please select at least one group."; 1311 return {success: false, message: message}; 1312 } 1313 1314 if (generateSeriesList(config.measures).length == 0) 1315 { 1316 message = "No series or dimension selected. Please select at least one series/dimension value."; 1317 return {success: false, message: message}; 1318 } 1319 1320 if (!(config.displayIndividual || config.displayAggregate)) 1321 { 1322 message = "Please select either \"Show Individual Lines\" or \"Show Mean\"."; 1323 return {success: false, message: message}; 1324 } 1325 1326 // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects 1327 var subjectLength = config.subject.values ? config.subject.values.length : 0; 1328 if (config.displayIndividual && subjectLength > 10000) 1329 { 1330 var nounPlural = getStudySubjectInfo().nounPlural; 1331 message = "Unable to display individual series lines for greater than 10,000 total " + nounPlural.toLowerCase() + "."; 1332 return {success: false, message: message}; 1333 } 1334 1335 return {success: true, message: message}; 1336 }; 1337 1338 /** 1339 * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make 1340 * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean) 1341 * and a message parameter (string). If the success pararameter is false there is a critical error and the chart 1342 * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning 1343 * message if applicable. If message is not null and success is true, there is a warning. 1344 * @param {Object} data The data object, from getChartData. 1345 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 1346 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 1347 * @param {int} limit The data limit for a single report. 1348 * @returns {Object} 1349 */ 1350 var validateChartData = function(data, config, seriesList, limit) { 1351 var message = "", 1352 sep = "", 1353 msg = "", 1354 commaSep = "", 1355 noDataCounter = 0; 1356 1357 // warn the user if the data limit has been reached 1358 var individualDataCount = LABKEY.Utils.isDefined(data.individual) ? data.individual.measureStore.records().length : null; 1359 var aggregateDataCount = LABKEY.Utils.isDefined(data.aggregate) ? data.aggregate.measureStore.records().length : null; 1360 if (individualDataCount >= limit || aggregateDataCount >= limit) { 1361 message += sep + "The data limit for plotting has been reached. Consider filtering your data."; 1362 sep = "<br/>"; 1363 } 1364 1365 // for date based charts, give error message if there are no calculated interval values 1366 if (!data.hasIntervalData) 1367 { 1368 message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'."; 1369 sep = "<br/>"; 1370 } 1371 1372 // check to see if any of the measures don't have data 1373 Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) { 1374 if (!value) 1375 { 1376 noDataCounter++; 1377 msg += commaSep + key; 1378 commaSep = ", "; 1379 } 1380 }, this); 1381 if (msg.length > 0) 1382 { 1383 msg = "No data found for the following measures/dimensions: " + msg; 1384 1385 // if there is no data for any series, add to explanation 1386 if (noDataCounter == seriesList.length) 1387 { 1388 var isDateBased = config && config.measures[0].time == "date"; 1389 if (isDateBased) 1390 msg += ". This may be the result of a missing start date value for the selected subject(s)."; 1391 } 1392 1393 message += sep + msg; 1394 sep = "<br/>"; 1395 } 1396 1397 // check to make sure that data can be used in a log scale (if applicable) 1398 if (config) 1399 { 1400 var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); 1401 var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); 1402 1403 Ext4.each(config.measures, function(md){ 1404 var m = md.measure; 1405 1406 // check the left y-axis 1407 if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log" 1408 && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name]) 1409 || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name]))) 1410 { 1411 config.axis[leftAxisIndex].scale = "linear"; 1412 message += sep + "Unable to use a log scale on the left y-axis. All y-axis values must be >= 0. Reverting to linear scale on left y-axis."; 1413 sep = "<br/>"; 1414 } 1415 1416 // check the right y-axis 1417 if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log" 1418 && ((data.individual && data.individual.hasNegativeValues[m.name]) 1419 || (data.aggregate && data.aggregate.hasNegativeValues[m.name]))) 1420 { 1421 config.axis[rightAxisIndex].scale = "linear"; 1422 message += sep + "Unable to use a log scale on the right y-axis. All y-axis values must be >= 0. Reverting to linear scale on right y-axis."; 1423 sep = "<br/>"; 1424 } 1425 1426 }); 1427 } 1428 1429 return {success: true, message: message}; 1430 }; 1431 1432 /** 1433 * Support backwards compatibility for charts saved prior to chartInfo reconfiguration (2011-08-31). 1434 * Support backwards compatibility for save thumbnail options (2012-06-19). 1435 * @param chartInfo 1436 * @param savedReportInfo 1437 */ 1438 var convertSavedReportConfig = function(chartInfo, savedReportInfo) 1439 { 1440 if (LABKEY.Utils.isDefined(chartInfo)) 1441 { 1442 Ext4.applyIf(chartInfo, { 1443 axis: [], 1444 //This is for charts saved prior to 2011-10-07 1445 chartSubjectSelection: chartInfo.chartLayout == 'per_group' ? 'groups' : 'subjects', 1446 displayIndividual: true, 1447 displayAggregate: false 1448 }); 1449 for (var i = 0; i < chartInfo.measures.length; i++) 1450 { 1451 var md = chartInfo.measures[i]; 1452 1453 Ext4.applyIf(md.measure, {yAxis: "left"}); 1454 1455 // if the axis info is in md, move it to the axis array 1456 if (md.axis) 1457 { 1458 // default the y-axis to the left side if not specified 1459 if (md.axis.name == "y-axis") 1460 Ext4.applyIf(md.axis, {side: "left"}); 1461 1462 // move the axis info to the axis array 1463 if (getAxisIndex(chartInfo.axis, md.axis.name, md.axis.side) == -1) 1464 chartInfo.axis.push(Ext4.apply({}, md.axis)); 1465 1466 // if the chartInfo has an x-axis measure, move the date info it to the related y-axis measures 1467 if (md.axis.name == "x-axis") 1468 { 1469 for (var j = 0; j < chartInfo.measures.length; j++) 1470 { 1471 var schema = md.measure.schemaName; 1472 var query = md.measure.queryName; 1473 if (chartInfo.measures[j].axis && chartInfo.measures[j].axis.name == "y-axis" 1474 && chartInfo.measures[j].measure.schemaName == schema 1475 && chartInfo.measures[j].measure.queryName == query) 1476 { 1477 chartInfo.measures[j].dateOptions = { 1478 dateCol: Ext4.apply({}, md.measure), 1479 zeroDateCol: Ext4.apply({}, md.dateOptions.zeroDateCol), 1480 interval: md.dateOptions.interval 1481 }; 1482 } 1483 } 1484 1485 // remove the x-axis date measure from the measures array 1486 chartInfo.measures.splice(i, 1); 1487 i--; 1488 } 1489 else 1490 { 1491 // remove the axis property from the measure 1492 delete md.axis; 1493 } 1494 } 1495 } 1496 } 1497 1498 if (LABKEY.Utils.isObject(chartInfo) && LABKEY.Utils.isObject(savedReportInfo)) 1499 { 1500 if (chartInfo.saveThumbnail != undefined) 1501 { 1502 if (savedReportInfo.reportProps == null) 1503 savedReportInfo.reportProps = {}; 1504 1505 Ext4.applyIf(savedReportInfo.reportProps, { 1506 thumbnailType: !chartInfo.saveThumbnail ? 'NONE' : 'AUTO' 1507 }); 1508 } 1509 } 1510 }; 1511 1512 var getStudySubjectInfo = function() 1513 { 1514 var studyCtx = LABKEY.getModuleContext("study") || {}; 1515 return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { 1516 tableName: 'Participant', 1517 columnName: 'ParticipantId', 1518 nounPlural: 'Participants', 1519 nounSingular: 'Participant' 1520 }; 1521 }; 1522 1523 var getMeasureAlias = function(measure) 1524 { 1525 if (LABKEY.Utils.isString(measure.alias)) 1526 return measure.alias; 1527 else 1528 return measure.schemaName + '_' + measure.queryName + '_' + measure.name; 1529 }; 1530 1531 var getMeasuresLabelBySide = function(measures, side) 1532 { 1533 var labels = []; 1534 Ext4.each(measures, function(measure) 1535 { 1536 if (measure.yAxis == side && labels.indexOf(measure.label) == -1) 1537 labels.push(measure.label); 1538 }); 1539 1540 return labels.join(', '); 1541 }; 1542 1543 var getDistinctYAxisSides = function(measures) 1544 { 1545 return Ext4.Array.unique(Ext4.Array.pluck(measures, 'yAxis')); 1546 }; 1547 1548 var _getRowValue = function(row, propName, valueName) 1549 { 1550 if (row.hasOwnProperty(propName)) { 1551 // backwards compatibility for response row that is not a LABKEY.Query.Row 1552 if (!(row instanceof LABKEY.Query.Row)) { 1553 return row[propName].displayValue || row[propName].value; 1554 } 1555 1556 var propValue = row.get(propName); 1557 if (valueName != undefined && propValue.hasOwnProperty(valueName)) { 1558 return propValue[valueName]; 1559 } 1560 else if (propValue.hasOwnProperty('displayValue')) { 1561 return propValue['displayValue']; 1562 } 1563 return row.getValue(propName); 1564 } 1565 1566 return undefined; 1567 }; 1568 1569 var renderChartSVG = function(renderTo, queryConfig, chartConfig) { 1570 // Before we load the data, validate some information about the chart config 1571 var messages = []; 1572 var validation = validateChartConfig(chartConfig); 1573 if (validation.message != null) 1574 { 1575 messages.push(validation.message); 1576 } 1577 if (!validation.success) 1578 { 1579 _renderMessages(renderTo, messages); 1580 return; 1581 } 1582 1583 var nounSingular = 'Participant'; 1584 var subjectColumnName = 'ParticipantId'; 1585 if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject) 1586 { 1587 nounSingular = LABKEY.moduleContext.study.subject.nounSingular; 1588 subjectColumnName = LABKEY.moduleContext.study.subject.columnName; 1589 } 1590 1591 // When all the dependencies are loaded, we load the data using time chart helper getChartData 1592 Ext4.applyIf(queryConfig, { 1593 chartInfo: chartConfig, 1594 containerPath: LABKEY.container.path, 1595 nounSingular: nounSingular, 1596 subjectColumnName: subjectColumnName, 1597 dataLimit: 10000, 1598 maxCharts: 20, 1599 defaultMultiChartHeight: 380, 1600 defaultSingleChartHeight: 600, 1601 defaultWidth: 1075, 1602 defaultNumberFormat: function(v) { return v.toFixed(1); } 1603 }); 1604 1605 queryConfig.success = function(response) { 1606 _getChartDataCallback(renderTo, queryConfig, chartConfig, response); 1607 }; 1608 queryConfig.failure = function(info) { 1609 _renderMessages(renderTo, ['Error: ' + info.exception]); 1610 }; 1611 1612 LABKEY.vis.TimeChartHelper.getChartData(queryConfig); 1613 }; 1614 1615 var _getChartDataCallback = function(renderTo, queryConfig, chartConfig, responseData) { 1616 var individualColumnAliases = responseData.individual ? responseData.individual.columnAliases : null; 1617 var aggregateColumnAliases = responseData.aggregate ? responseData.aggregate.columnAliases : null; 1618 var visitMap = responseData.individual ? responseData.individual.visitMap : responseData.aggregate.visitMap; 1619 var intervalKey = generateIntervalKey(chartConfig, individualColumnAliases, aggregateColumnAliases, queryConfig.nounSingular); 1620 var aes = generateAes(chartConfig, visitMap, individualColumnAliases, intervalKey, queryConfig.subjectColumnName); 1621 var tickMap = generateTickMap(visitMap); 1622 var seriesList = generateSeriesList(chartConfig.measures); 1623 var applyClipRect = generateApplyClipRect(chartConfig); 1624 1625 // Once we have the data, we can set all of the axis min/max range values 1626 generateAcrossChartAxisRanges(chartConfig, responseData, seriesList, queryConfig.nounSingular); 1627 var scales = generateScales(chartConfig, tickMap, responseData.numberFormats); 1628 1629 // Validate that the chart data has expected values and give warnings if certain elements are not present 1630 var messages = []; 1631 var validation = validateChartData(responseData, chartConfig, seriesList, queryConfig.dataLimit, false); 1632 if (validation.message != null) 1633 { 1634 messages.push(validation.message); 1635 } 1636 if (!validation.success) 1637 { 1638 _renderMessages(renderTo, messages); 1639 return; 1640 } 1641 1642 // For time charts, we allow multiple plots to be displayed by participant, group, or measure/dimension 1643 var plotConfigsArr = generatePlotConfigs(chartConfig, responseData, seriesList, applyClipRect, queryConfig.maxCharts, queryConfig.subjectColumnName); 1644 for (var configIndex = 0; configIndex < plotConfigsArr.length; configIndex++) 1645 { 1646 var clipRect = plotConfigsArr[configIndex].applyClipRect; 1647 var series = plotConfigsArr[configIndex].series; 1648 var height = chartConfig.height || (plotConfigsArr.length > 1 ? queryConfig.defaultMultiChartHeight : queryConfig.defaultSingleChartHeight); 1649 var width = chartConfig.width || queryConfig.defaultWidth; 1650 var labels = generateLabels(plotConfigsArr[configIndex].title, chartConfig.axis, plotConfigsArr[configIndex].subtitle); 1651 var layers = generateLayers(chartConfig, visitMap, individualColumnAliases, aggregateColumnAliases, plotConfigsArr[configIndex].aggregateData, series, intervalKey, queryConfig.subjectColumnName); 1652 var data = plotConfigsArr[configIndex].individualData ? plotConfigsArr[configIndex].individualData : plotConfigsArr[configIndex].aggregateData; 1653 1654 var plotConfig = { 1655 renderTo: renderTo, 1656 rendererType: 'd3', 1657 clipRect: clipRect, 1658 width: width, 1659 height: height, 1660 labels: labels, 1661 aes: aes, 1662 scales: scales, 1663 layers: layers, 1664 data: data 1665 }; 1666 1667 var plot = new LABKEY.vis.Plot(plotConfig); 1668 plot.render(); 1669 } 1670 1671 // Give a warning if the max number of charts has been exceeded 1672 if (plotConfigsArr.length >= queryConfig.maxCharts) 1673 messages.push('Only showing the first ' + queryConfig.maxCharts + ' charts.'); 1674 1675 _renderMessages(renderTo, messages); 1676 }; 1677 1678 var _renderMessages = function(id, messages) { 1679 var messageDiv; 1680 var el = document.getElementById(id); 1681 var child; 1682 if (el && el.children.length > 0) 1683 child = el.children[0]; 1684 1685 for (var i = 0; i < messages.length; i++) 1686 { 1687 messageDiv = document.createElement('div'); 1688 messageDiv.setAttribute('style', 'font-style:italic'); 1689 messageDiv.innerHTML = messages[i]; 1690 if (child) 1691 el.insertBefore(messageDiv, child); 1692 else 1693 el.appendChild(messageDiv); 1694 } 1695 }; 1696 1697 return { 1698 /** 1699 * Loads all of the required dependencies for a Time Chart. 1700 * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. 1701 * @param {Object} scope The scope to be used when executing the callback. 1702 */ 1703 loadVisDependencies: LABKEY.requiresVisualization, 1704 generateAcrossChartAxisRanges : generateAcrossChartAxisRanges, 1705 generateAes : generateAes, 1706 generateApplyClipRect : generateApplyClipRect, 1707 generateIntervalKey : generateIntervalKey, 1708 generateLabels : generateLabels, 1709 generateLayers : generateLayers, 1710 generatePlotConfigs : generatePlotConfigs, 1711 generateScales : generateScales, 1712 generateSeriesList : generateSeriesList, 1713 generateTickMap : generateTickMap, 1714 generateNumberFormats : generateNumberFormats, 1715 getAxisIndex : getAxisIndex, 1716 getMeasureAlias : getMeasureAlias, 1717 getMeasuresLabelBySide : getMeasuresLabelBySide, 1718 getDistinctYAxisSides : getDistinctYAxisSides, 1719 getStudyTimepointType : getStudyTimepointType, 1720 getStudySubjectInfo : getStudySubjectInfo, 1721 getStudyMeasures : getStudyMeasures, 1722 getChartData : getChartData, 1723 validateChartConfig : validateChartConfig, 1724 validateChartData : validateChartData, 1725 convertSavedReportConfig : convertSavedReportConfig, 1726 renderChartSVG: renderChartSVG 1727 }; 1728 }; 1729