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