1 /* 2 * Copyright (c) 2013-2016 LabKey Corporation 3 * 4 * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 5 */ 6 if(!LABKEY.vis) { 7 LABKEY.vis = {}; 8 } 9 10 /** 11 * @namespace Namespace used to encapsulate functions related to creating study Time Charts. Used in the 12 * Time Chart Wizard and when exporting Time Charts as scripts. 13 */ 14 LABKEY.vis.TimeChartHelper = new function() { 15 16 var studyNounSingular = 'Participant', 17 studyNounPlural = 'Participants', 18 studyNounColumnName = 'ParticipantId'; 19 20 if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject) 21 { 22 studyNounSingular = LABKEY.moduleContext.study.subject.nounSingular; 23 studyNounPlural = LABKEY.moduleContext.study.subject.nounPlural; 24 studyNounColumnName = LABKEY.moduleContext.study.subject.columnName; 25 } 26 27 /** 28 * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels. 29 * @param {String} mainTitle The label to be used as the main chart title. 30 * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels. 31 * @returns {Object} 32 */ 33 var generateLabels = function(mainTitle, axisArr) { 34 var xTitle = '', yLeftTitle = '', yRightTitle = ''; 35 for (var i = 0; i < axisArr.length; i++) 36 { 37 var axis = axisArr[i]; 38 if (axis.name == "y-axis") 39 { 40 if (axis.side == "left") 41 yLeftTitle = axis.label; 42 else 43 yRightTitle = axis.label; 44 } 45 else 46 { 47 xTitle = axis.label; 48 } 49 } 50 51 return { 52 main : { 53 value : mainTitle 54 }, 55 x : { 56 value : xTitle 57 }, 58 yLeft : { 59 value : yLeftTitle 60 }, 61 yRight : { 62 value : yRightTitle 63 } 64 }; 65 }; 66 67 /** 68 * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. 69 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 70 * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap. 71 * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks. 72 * @returns {Object} 73 */ 74 var generateScales = function(config, tickMap, numberFormats) { 75 if (config.measures.length == 0) 76 throw "There must be at least one specified measure in the chartInfo config!"; 77 78 var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText, 79 yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat, 80 yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat; 81 82 for (var i = 0; i < config.axis.length; i++) 83 { 84 var axis = config.axis[i]; 85 if (axis.name == "y-axis") 86 { 87 if (axis.side == "left") 88 { 89 yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); 90 yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); 91 yLeftTrans = axis.scale ? axis.scale : "linear"; 92 yLeftTickFormat = numberFormats.left ? numberFormats.left : null; 93 } 94 else 95 { 96 yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); 97 yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); 98 yRightTrans = axis.scale ? axis.scale : "linear"; 99 yRightTickFormat = numberFormats.right ? numberFormats.right : null; 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 if (config.measures.length == 0) 165 throw "There must be at least one specified measure in the chartInfo config!"; 166 if (!individualColumnAliases && !aggregateColumnAliases) 167 throw "We expect to either be displaying individual series lines or aggregate data!"; 168 169 if (config.measures[0].time == "date") 170 { 171 return config.measures[0].dateOptions.interval; 172 } 173 else 174 { 175 return individualColumnAliases ? 176 LABKEY.vis.getColumnAlias(individualColumnAliases, (nounSingular || studyNounSingular) + "Visit/Visit") : 177 LABKEY.vis.getColumnAlias(aggregateColumnAliases, (nounSingular || studyNounSingular) + "Visit/Visit"); 178 } 179 }; 180 181 /** 182 * Generate that x-axis tick mark mapping for a visit based chart. 183 * @param {Object} visitMap For visit based charts, the study visit information map. 184 * @returns {Object} 185 */ 186 var generateTickMap = function(visitMap) { 187 var tickMap = {}; 188 for (var rowId in visitMap) 189 { 190 if (visitMap.hasOwnProperty(rowId)) 191 { 192 tickMap[visitMap[rowId].displayOrder] = { 193 label: visitMap[rowId].displayName, 194 description: visitMap[rowId].description || visitMap[rowId].displayName 195 }; 196 } 197 } 198 199 return tickMap; 200 }; 201 202 /** 203 * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} 204 * and {@link LABKEY.vis.Layer}. 205 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 206 * @param {Object} visitMap For visit based charts, the study visit information map. 207 * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. 208 * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. 209 * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). 210 * @returns {Object} 211 */ 212 var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName) { 213 if (config.measures.length == 0) 214 throw "There must be at least one specified measure in the chartInfo config!"; 215 216 var xAes; 217 if (config.measures[0].time == "date") 218 xAes = function(row) { return (row[intervalKey] ? row[intervalKey].value : null); }; 219 else 220 xAes = function(row) { return visitMap[row[intervalKey].value].displayOrder; }; 221 222 var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName || studyNounColumnName) : null; 223 224 return { 225 x: xAes, 226 color: function(row) { return (row[individualSubjectColumn] ? row[individualSubjectColumn].value : null); }, 227 group: function(row) { return (row[individualSubjectColumn] ? row[individualSubjectColumn].value : null); }, 228 shape: function(row) { return (row[individualSubjectColumn] ? row[individualSubjectColumn].value : null); }, 229 pathColor: function(rows) { return (rows[0][individualSubjectColumn] ? rows[0][individualSubjectColumn].value : null); } 230 }; 231 }; 232 233 /** 234 * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list. 235 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 236 * @param {Object} visitMap For visit based charts, the study visit information map. 237 * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. 238 * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. 239 * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData. 240 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 241 * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. 242 * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). 243 * @returns {Array} 244 */ 245 var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName) { 246 if (config.measures.length == 0) 247 throw "There must be at least one specified measure in the chartInfo config!"; 248 if (!individualColumnAliases && !aggregateColumnAliases) 249 throw "We expect to either be displaying individual series lines or aggregate data!"; 250 251 var layers = []; 252 var isDateBased = config.measures[0].time == "date"; 253 var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName || studyNounColumnName) : null; 254 var aggregateSubjectColumn = "UniqueId"; 255 256 var generateLayerAes = function(name, yAxisSide, columnName){ 257 var yName = yAxisSide == "left" ? "yLeft" : "yRight"; 258 var aes = {}; 259 aes[yName] = function(row){return (row[columnName] ? parseFloat(row[columnName].value) : null)}; // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. 260 return aes; 261 }; 262 263 var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){ 264 var yName = yAxisSide == "left" ? "yLeft" : "yRight"; 265 var aes = {}; 266 aes[yName] = function(row){return (row[columnName] ? parseFloat(row[columnName].value) : null)}; // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. 267 aes.group = aes.color = aes.shape = function(row){return row[subjectColumn].displayValue}; 268 aes.pathColor = function(rows){return rows[0][subjectColumn].displayValue}; 269 aes.error = function(row){return (row[errorColumn] ? row[errorColumn].value : null)}; 270 return aes; 271 }; 272 273 var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){ 274 if (visitMap) 275 { 276 if (errorColumn) 277 { 278 return function(row){ 279 var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value; 280 var errorVal = row[errorColumn].value ? row[errorColumn].value : 'n/a'; 281 return ' ' + subject + ',\n '+ visitMap[row[intervalKey].value].displayName + ',\n ' + name + ': ' + row[columnName].value + 282 ',\n ' + errorType + ': ' + errorVal; 283 } 284 } 285 else 286 { 287 return function(row){ 288 var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value; 289 return ' ' + subject + ',\n '+ visitMap[row[intervalKey].value].displayName + ',\n ' + name + ': ' + row[columnName].value; 290 }; 291 } 292 } 293 else 294 { 295 if (errorColumn) 296 { 297 return function(row){ 298 var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value; 299 var errorVal = row[errorColumn].value ? row[errorColumn].value : 'n/a'; 300 return ' ' + subject + ',\n ' + intervalKey + ': ' + row[intervalKey].value + ',\n ' + name + ': ' + row[columnName].value + 301 ',\n ' + errorType + ': ' + errorVal; 302 }; 303 } 304 else 305 { 306 return function(row){ 307 var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value; 308 return ' ' + subject + ',\n ' + intervalKey + ': ' + row[intervalKey].value + ',\n ' + name + ': ' + row[columnName].value; 309 }; 310 } 311 } 312 }; 313 314 // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique) 315 // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique) 316 var useUniqueSeriesNames = false; 317 var uniqueChartSeriesNames = []; 318 for (var i = 0; i < seriesList.length; i++) 319 { 320 if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1) 321 { 322 useUniqueSeriesNames = true; 323 break; 324 } 325 uniqueChartSeriesNames.push(seriesList[i].name); 326 } 327 328 for (var i = seriesList.length -1; i >= 0; i--) 329 { 330 var chartSeries = seriesList[i]; 331 332 var chartSeriesName = chartSeries.label; 333 if (useUniqueSeriesNames) 334 { 335 if (chartSeries.aliasLookupInfo.pivotValue) 336 chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue; 337 else 338 chartSeriesName = chartSeries.aliasLookupInfo.alias; 339 } 340 341 var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo); 342 if (individualColumnAliases) 343 { 344 var pathLayerConfig = { 345 geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), 346 aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) 347 }; 348 349 if (seriesList.length > 1) 350 pathLayerConfig.name = chartSeriesName; 351 352 layers.push(new LABKEY.vis.Layer(pathLayerConfig)); 353 354 if (!config.hideDataPoints) 355 { 356 var pointLayerConfig = { 357 geom: new LABKEY.vis.Geom.Point(), 358 aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) 359 }; 360 361 if (seriesList.length > 1) 362 pointLayerConfig.name = chartSeriesName; 363 364 if (isDateBased) 365 pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null); 366 else 367 pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null); 368 369 if (config.pointClickFn) 370 { 371 pointLayerConfig.aes.pointClickFn = generatePointClickFn( 372 config.pointClickFn, 373 {participant: individualSubjectColumn, interval: intervalKey, measure: columnName}, 374 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} 375 ); 376 } 377 378 layers.push(new LABKEY.vis.Layer(pointLayerConfig)); 379 } 380 } 381 382 if (aggregateData && aggregateColumnAliases) 383 { 384 var errorBarType = null; 385 if (config.errorBars == 'SD') 386 errorBarType = '_STDDEV'; 387 else if (config.errorBars == 'SEM') 388 errorBarType = '_STDERR'; 389 390 var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null; 391 392 var aggregatePathLayerConfig = { 393 data: aggregateData, 394 geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), 395 aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) 396 }; 397 398 if (seriesList.length > 1) 399 aggregatePathLayerConfig.name = chartSeriesName; 400 401 layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig)); 402 403 if (errorColumnName) 404 { 405 var aggregateErrorLayerConfig = { 406 data: aggregateData, 407 geom: new LABKEY.vis.Geom.ErrorBar(), 408 aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) 409 }; 410 411 if (seriesList.length > 1) 412 aggregateErrorLayerConfig.name = chartSeriesName; 413 414 layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig)); 415 } 416 417 if (!config.hideDataPoints) 418 { 419 var aggregatePointLayerConfig = { 420 data: aggregateData, 421 geom: new LABKEY.vis.Geom.Point(), 422 aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) 423 }; 424 425 if (seriesList.length > 1) 426 aggregatePointLayerConfig.name = chartSeriesName; 427 428 if (isDateBased) 429 aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars) 430 else 431 aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars); 432 433 if (config.pointClickFn) 434 { 435 aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn( 436 config.pointClickFn, 437 {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName}, 438 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} 439 ); 440 } 441 442 layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig)); 443 } 444 } 445 } 446 447 return layers; 448 }; 449 450 // private function 451 var generatePointClickFn = function(fnString, columnMap, measureInfo){ 452 // the developer is expected to return a function, so we encapalate it within the anonymous function 453 // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel) 454 455 // using new Function is quicker than eval(), even in IE. 456 var pointClickFn = new Function('return ' + fnString)(); 457 return function(clickEvent, data) { 458 pointClickFn(data, columnMap, measureInfo, clickEvent); 459 }; 460 }; 461 462 /** 463 * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and 464 * dimension that is selected in the chart. 465 * @param {Array} measures The array of selected measures from the chart config. 466 * @returns {Array} 467 */ 468 var generateSeriesList = function(measures) { 469 var arr = []; 470 for (var i = 0; i < measures.length; i++) 471 { 472 var md = measures[i]; 473 474 if (md.dimension && md.dimension.values) 475 { 476 Ext4.each(md.dimension.values, function(val) { 477 arr.push({ 478 schemaName: md.dimension.schemaName, 479 queryName: md.dimension.queryName, 480 name: val, 481 label: val, 482 measureIndex: i, 483 yAxisSide: md.measure.yAxis, 484 aliasLookupInfo: {measureName: md.measure.name, pivotValue: val} 485 }); 486 }); 487 } 488 else 489 { 490 arr.push({ 491 schemaName: md.measure.schemaName, 492 queryName: md.measure.queryName, 493 name: md.measure.name, 494 label: md.measure.label, 495 measureIndex: i, 496 yAxisSide: md.measure.yAxis, 497 aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name} 498 }); 499 } 500 } 501 return arr; 502 }; 503 504 // private function 505 var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular) { 506 return [ 507 subject, 508 { 509 schemaName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, 510 queryName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, 511 name : isDateBased ? firstMeasure.dateOptions.dateCol.name : (nounSingular || studyNounSingular) + "Visit/Visit/DisplayOrder" 512 }, 513 { 514 schemaName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, 515 queryName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, 516 name : (nounSingular || studyNounSingular) + (isDateBased ? "Visit/Visit" : "Visit/Visit/SequenceNumMin") 517 } 518 ]; 519 }; 520 521 /** 522 * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges. 523 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 524 * @returns {boolean} 525 */ 526 var generateApplyClipRect = function(config) { 527 var xAxisIndex = getAxisIndex(config.axis, "x-axis"); 528 var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); 529 var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); 530 531 return ( 532 xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) || 533 leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) || 534 rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null) 535 ); 536 }; 537 538 /** 539 * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple 540 * charts that are set to use the same axis ranges across all charts in the report. 541 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 542 * @param {Object} data The data object, from getChartData. 543 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 544 * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). 545 */ 546 var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular) { 547 if (config.measures.length == 0) 548 throw "There must be at least one specified measure in the chartInfo config!"; 549 if (!data.individual && !data.aggregate) 550 throw "We expect to either be displaying individual series lines or aggregate data!"; 551 552 var rows = data.individual ? data.individual.rows : (data.aggregate ? data.aggregate.rows : []); 553 config.hasNoData = rows.length == 0; 554 555 // In multi-chart case, we need to precompute the default axis ranges so that all charts share them 556 // (if 'automatic across charts' is selected for the given axis) 557 if (config.chartLayout != "single") 558 { 559 var leftMeasures = [], 560 rightMeasures = [], 561 xName, xFunc, 562 min, max, tempMin, tempMax, errorBarType, 563 leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor, 564 columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null), 565 isDateBased = config.measures[0].time == "date", 566 xAxisIndex = getAxisIndex(config.axis, "x-axis"), 567 leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"), 568 rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); 569 570 for (var i = 0; i < seriesList.length; i++) 571 { 572 var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo); 573 if (seriesList[i].yAxisSide == "left") 574 leftMeasures.push(columnName); 575 else if (seriesList[i].yAxisSide == "right") 576 rightMeasures.push(columnName); 577 } 578 579 if (isDateBased) 580 { 581 xName = config.measures[0].dateOptions.interval; 582 xFunc = function(row){ 583 return row[xName].value; 584 }; 585 } 586 else 587 { 588 var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap; 589 xName = LABKEY.vis.getColumnAlias(columnAliases, (nounSingular || studyNounSingular) + "Visit/Visit"); 590 xFunc = function(row){ 591 return visitMap[row[xName].value].displayOrder; 592 }; 593 } 594 595 if (config.axis[xAxisIndex].range.type != 'automatic_per_chart') 596 { 597 if (!config.axis[xAxisIndex].range.min) 598 config.axis[xAxisIndex].range.min = d3.min(rows, xFunc); 599 600 if (!config.axis[xAxisIndex].range.max) 601 config.axis[xAxisIndex].range.max = d3.max(rows, xFunc); 602 } 603 604 if (config.errorBars !== 'None') 605 errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR'; 606 607 if (leftAxisIndex > -1) 608 { 609 // If we have a left axis then we need to find the min/max 610 min = null; max = null; tempMin = null; tempMax = null; 611 leftAccessor = function(row) { 612 return (row[leftMeasures[i]] ? row[leftMeasures[i]].value : null); 613 }; 614 615 if (errorBarType) 616 { 617 // If we have error bars we need to calculate min/max with the error values in mind. 618 leftAccessorMin = function(row){ 619 if (row[leftMeasures[i] + errorBarType]) 620 { 621 var error = row[leftMeasures[i] + errorBarType].value; 622 return row[leftMeasures[i]].value - error; 623 } 624 else 625 return null; 626 }; 627 628 leftAccessorMax = function(row){ 629 if (row[leftMeasures[i] + errorBarType]) 630 { 631 var error = row[leftMeasures[i] + errorBarType].value; 632 return row[leftMeasures[i]].value + error; 633 } 634 else 635 return null; 636 }; 637 } 638 639 if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart') 640 { 641 if (!config.axis[leftAxisIndex].range.min) 642 { 643 for (var i = 0; i < leftMeasures.length; i++) 644 { 645 tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor); 646 min = min == null ? tempMin : tempMin < min ? tempMin : min; 647 } 648 config.axis[leftAxisIndex].range.min = min; 649 } 650 651 if (!config.axis[leftAxisIndex].range.max) 652 { 653 for (var i = 0; i < leftMeasures.length; i++) 654 { 655 tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor); 656 max = max == null ? tempMax : tempMax > max ? tempMax : max; 657 } 658 config.axis[leftAxisIndex].range.max = max; 659 } 660 } 661 } 662 663 if (rightAxisIndex > -1) 664 { 665 // If we have a right axis then we need to find the min/max 666 min = null; max = null; tempMin = null; tempMax = null; 667 rightAccessor = function(row){ 668 return row[rightMeasures[i]].value 669 }; 670 671 if (errorBarType) 672 { 673 rightAccessorMin = function(row){ 674 var error = row[rightMeasures[i] + errorBarType].value; 675 return row[rightMeasures[i]].value - error; 676 }; 677 678 rightAccessorMax = function(row){ 679 var error = row[rightMeasures[i] + errorBarType].value; 680 return row[rightMeasures[i]].value + error; 681 }; 682 } 683 684 if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart') 685 { 686 if (!config.axis[rightAxisIndex].range.min) 687 { 688 for (var i = 0; i < rightMeasures.length; i++) 689 { 690 tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor); 691 min = min == null ? tempMin : tempMin < min ? tempMin : min; 692 } 693 config.axis[rightAxisIndex].range.min = min; 694 } 695 696 if (!config.axis[rightAxisIndex].range.max) 697 { 698 for (var i = 0; i < rightMeasures.length; i++) 699 { 700 tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor); 701 max = max == null ? tempMax : tempMax > max ? tempMax : max; 702 } 703 config.axis[rightAxisIndex].range.max = max; 704 } 705 } 706 } 707 } 708 }; 709 710 /** 711 * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report. 712 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 713 * @param {Object} data The data object, from getChartData. 714 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 715 * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect. 716 * @param {int} maxCharts The maximum number of charts to display in one report. 717 * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). 718 * @returns {Array} 719 */ 720 var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) { 721 var plotConfigInfoArr = [], 722 subjectColumnName = null; 723 724 if (data.individual) 725 subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName || studyNounColumnName); 726 727 var generateGroupSeries = function(rows, groups, subjectColumn) { 728 // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array 729 // groups is config.subject.groups 730 var dataByGroup = {}; 731 732 for (var i = 0; i < rows.length; i++) 733 { 734 var rowSubject = rows[i][subjectColumn].value; 735 for (var j = 0; j < groups.length; j++) 736 { 737 if (groups[j].participantIds.indexOf(rowSubject) > -1) 738 { 739 if (!dataByGroup[groups[j].label]) 740 dataByGroup[groups[j].label] = []; 741 742 dataByGroup[groups[j].label].push(rows[i]); 743 } 744 } 745 } 746 747 return dataByGroup; 748 }; 749 750 var concatChartTitle = function(mainTitle, subTitle) { 751 return mainTitle + (mainTitle ? ': ' : '') + subTitle; 752 }; 753 754 // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension 755 if (config.chartLayout == "per_subject") 756 { 757 var dataPerParticipant = getDataWithSeriesCheck(data.individual.rows, function(row){return row[subjectColumnName].value}, seriesList, data.individual.columnAliases); 758 for (var participant in dataPerParticipant) 759 { 760 if (dataPerParticipant.hasOwnProperty(participant)) 761 { 762 // skip the group if there is no data for it 763 if (!dataPerParticipant[participant].hasSeriesData) 764 continue; 765 766 plotConfigInfoArr.push({ 767 title: concatChartTitle(config.title, participant), 768 series: seriesList, 769 individualData: dataPerParticipant[participant].data, 770 style: config.subject.values.length > 1 ? 'border-bottom: solid black 1px;' : null, 771 applyClipRect: applyClipRect 772 }); 773 774 if (plotConfigInfoArr.length > maxCharts) 775 break; 776 } 777 } 778 } 779 else if (config.chartLayout == "per_group") 780 { 781 var groupedIndividualData = null, groupedAggregateData = null; 782 783 //Display individual lines 784 if (data.individual) 785 groupedIndividualData = generateGroupSeries(data.individual.rows, config.subject.groups, subjectColumnName); 786 787 // Display aggregate lines 788 if (data.aggregate) 789 groupedAggregateData = getDataWithSeriesCheck(data.aggregate.rows, function(row){return row.UniqueId.displayValue}, seriesList, data.aggregate.columnAliases); 790 791 for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++) 792 { 793 var group = config.subject.groups[i]; 794 795 // skip the group if there is no data for it 796 if ((groupedIndividualData != null && !groupedIndividualData[group.label]) 797 || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData))) 798 { 799 continue; 800 } 801 802 plotConfigInfoArr.push({ 803 title: concatChartTitle(config.title, group.label), 804 series: seriesList, 805 individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null, 806 aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null, 807 style: config.subject.groups.length > 1 ? 'border-bottom: solid black 1px;' : null, 808 applyClipRect: applyClipRect 809 }); 810 811 if (plotConfigInfoArr.length > maxCharts) 812 break; 813 } 814 } 815 else if (config.chartLayout == "per_dimension") 816 { 817 for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++) 818 { 819 // skip the measure/dimension if there is no data for it 820 if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name]) 821 || (data.individual && !data.individual.hasData[seriesList[i].name])) 822 { 823 continue; 824 } 825 826 plotConfigInfoArr.push({ 827 title: concatChartTitle(config.title, seriesList[i].label), 828 series: [seriesList[i]], 829 individualData: data.individual ? data.individual.rows : null, 830 aggregateData: data.aggregate ? data.aggregate.rows : null, 831 style: seriesList.length > 1 ? 'border-bottom: solid black 1px;' : null, 832 applyClipRect: applyClipRect 833 }); 834 835 if (plotConfigInfoArr.length > maxCharts) 836 break; 837 } 838 } 839 else if (config.chartLayout == "single") 840 { 841 //Single Line Chart, with all participants or groups. 842 plotConfigInfoArr.push({ 843 title: config.title, 844 series: seriesList, 845 individualData: data.individual ? data.individual.rows : null, 846 aggregateData: data.aggregate ? data.aggregate.rows : null, 847 height: 610, 848 style: null, 849 applyClipRect: applyClipRect 850 }); 851 } 852 853 return plotConfigInfoArr; 854 }; 855 856 // private function 857 var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) { 858 /* 859 Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor. 860 Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData 861 */ 862 var groupedData = {}; 863 for (var i = 0; i < data.length; i++) 864 { 865 var value = groupAccessor(data[i]); 866 if (!groupedData[value]) 867 { 868 groupedData[value] = {data: [], hasSeriesData: false}; 869 } 870 groupedData[value].data.push(data[i]); 871 872 for (var j = 0; j < seriesList.length; j++) 873 { 874 var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo); 875 if (seriesAlias && data[i][seriesAlias] && data[i][seriesAlias].value) 876 { 877 groupedData[value].hasSeriesData = true; 878 break; 879 } 880 } 881 } 882 return groupedData; 883 }; 884 885 /** 886 * Get the index in the axes array for a given axis (ie left y-axis). 887 * @param {Array} axes The array of specified axis information for this chart. 888 * @param {String} axisName The chart axis (i.e. x-axis or y-axis). 889 * @param {String} [side] The y-axis side (i.e. left or right). 890 * @returns {number} 891 */ 892 var getAxisIndex = function(axes, axisName, side) { 893 var index = -1; 894 for (var i = 0; i < axes.length; i++) 895 { 896 if (!side && axes[i].name == axisName) 897 { 898 index = i; 899 break; 900 } 901 else if (axes[i].name == axisName && axes[i].side == side) 902 { 903 index = i; 904 break; 905 } 906 } 907 return index; 908 }; 909 910 /** 911 * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the 912 * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data. 913 * Calls the success callback function in the config when it has received all of the requested data. 914 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 915 */ 916 var getChartData = function(config) { 917 if (!config.success) 918 throw "You must specify a success callback function!"; 919 if (!config.failure) 920 throw "You must specify a failure callback function!"; 921 if (!config.chartInfo) 922 throw "You must specify a chartInfo config!"; 923 if (config.chartInfo.measures.length == 0) 924 throw "There must be at least one specified measure in the chartInfo config!"; 925 if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate) 926 throw "We expect to either be displaying individual series lines or aggregate data!"; 927 928 // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects 929 var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0; 930 if (config.chartInfo.displayIndividual && subjectLength > 10000) 931 { 932 config.chartInfo.displayIndividual = false; 933 config.chartInfo.subject.values = undefined; 934 } 935 936 var chartData = {numberFormats: {}}; 937 var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1; 938 var isDateBased = config.chartInfo.measures[0].time == "date"; 939 var seriesList = generateSeriesList(config.chartInfo.measures); 940 941 // Issue 16156: for date based charts, give error message if there are no calculated interval values 942 chartData.hasIntervalData = !isDateBased; 943 var checkForIntervalValues = function(row) { 944 if (isDateBased) 945 { 946 var intervalAlias = config.chartInfo.measures[0].dateOptions.interval; 947 if (row[intervalAlias] && row[intervalAlias].value != null) 948 chartData.hasIntervalData = true; 949 } 950 }; 951 952 var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) { 953 // get the visit map info for those visits in the response data 954 var trimmedVisits = []; 955 for (var v in origVisitMap) 956 { 957 if (origVisitMap.hasOwnProperty(v)) 958 { 959 if (visitsInDataArr.indexOf(v) != -1) 960 { 961 trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v])); 962 } 963 } 964 } 965 // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1 966 trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder}); 967 var newVisitMap = {}; 968 for (var i = 0; i < trimmedVisits.length; i++) 969 { 970 trimmedVisits[i].displayOrder = i + 1; 971 newVisitMap[trimmedVisits[i].id] = trimmedVisits[i]; 972 } 973 974 return newVisitMap; 975 }; 976 977 var successCallback = function(response, dataType) { 978 979 // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response 980 // also keep track of which measure/dimensions have negative values (for log scale) 981 var visitsInData = []; 982 response.hasData = {}; 983 response.hasNegativeValues = {}; 984 Ext4.each(seriesList, function(s) { 985 response.hasData[s.name] = false; 986 response.hasNegativeValues[s.name] = false; 987 for (var i = 0; i < response.rows.length; i++) 988 { 989 var row = response.rows[i]; 990 var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo); 991 if (row[alias] && row[alias].value != null) 992 { 993 response.hasData[s.name] = true; 994 995 if (row[alias].value < 0) 996 response.hasNegativeValues[s.name] = true; 997 } 998 999 var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, (config.nounSingular || studyNounSingular) + "Visit/Visit"); 1000 if (!isDateBased && row[visitMappedName]) 1001 { 1002 var visitVal = row[visitMappedName].value; 1003 if (visitsInData.indexOf(visitVal) == -1) 1004 visitsInData.push(visitVal.toString()); 1005 } 1006 1007 checkForIntervalValues(row); 1008 } 1009 }); 1010 1011 // trim the visit map domain to just those visits in the response data 1012 response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData); 1013 1014 chartData[dataType] = response; 1015 1016 generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat); 1017 1018 // if we have all request data back, return the result 1019 counter--; 1020 if (counter == 0) 1021 config.success.call(config.scope, chartData); 1022 }; 1023 1024 if (config.chartInfo.displayIndividual) 1025 { 1026 //Get data for individual lines. 1027 LABKEY.Query.Visualization.getData({ 1028 containerPath: config.containerPath, 1029 success: function(response) { 1030 successCallback(response, "individual"); 1031 }, 1032 failure : function(info, response, options) { 1033 config.failure.call(config.scope, info); 1034 }, 1035 measures: config.chartInfo.measures, 1036 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), 1037 limit : config.dataLimit || 10000, 1038 parameters : config.chartInfo.parameters, 1039 filterUrl: config.chartInfo.filterUrl, 1040 filterQuery: config.chartInfo.filterQuery 1041 }); 1042 } 1043 1044 if (config.chartInfo.displayAggregate) 1045 { 1046 //Get data for Aggregates lines. 1047 var groups = []; 1048 for (var i = 0; i < config.chartInfo.subject.groups.length; i++) 1049 { 1050 var group = config.chartInfo.subject.groups[i]; 1051 // encode the group id & type, so we can distinguish between cohort and participant group in the union table 1052 groups.push(group.id + '-' + group.type); 1053 } 1054 1055 LABKEY.Query.Visualization.getData({ 1056 containerPath: config.containerPath, 1057 success: function(response) { 1058 successCallback(response, "aggregate"); 1059 }, 1060 failure : function(info) { 1061 config.failure.call(config.scope, info); 1062 }, 1063 measures: config.chartInfo.measures, 1064 groupBys: [ 1065 // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first 1066 {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]}, 1067 {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups} 1068 ], 1069 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), 1070 limit : config.dataLimit || 10000, 1071 parameters : config.chartInfo.parameters, 1072 filterUrl: config.chartInfo.filterUrl, 1073 filterQuery: config.chartInfo.filterQuery 1074 }); 1075 } 1076 }; 1077 1078 /** 1079 * Generate the number format functions for the left and right y-axis and attach them to the chart data object 1080 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 1081 * @param {Object} data The data object, from getChartData. 1082 * @param {Object} defaultNumberFormat 1083 */ 1084 var generateNumberFormats = function(config, data, defaultNumberFormat) { 1085 var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields; 1086 1087 for (var i = 0; i < config.axis.length; i++) 1088 { 1089 var axis = config.axis[i]; 1090 if (axis.side) 1091 { 1092 // Find the first measure with the matching side that has a numberFormat. 1093 for (var j = 0; j < config.measures.length; j++) 1094 { 1095 var measure = config.measures[j].measure; 1096 1097 if (data.numberFormats[axis.side]) 1098 break; 1099 1100 if (measure.yAxis == axis.side) 1101 { 1102 var metaDataName = measure.alias; 1103 for (var k = 0; k < fields.length; k++) 1104 { 1105 var field = fields[k]; 1106 if (field.name == metaDataName) 1107 { 1108 if (field.extFormatFn) 1109 { 1110 data.numberFormats[axis.side] = eval(field.extFormatFn); 1111 break; 1112 } 1113 } 1114 } 1115 } 1116 } 1117 1118 if (!data.numberFormats[axis.side]) 1119 { 1120 // If after all the searching we still don't have a numberformat use the default number format. 1121 data.numberFormats[axis.side] = defaultNumberFormat; 1122 } 1123 } 1124 } 1125 }; 1126 1127 /** 1128 * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc. 1129 * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter 1130 * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered. 1131 * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning. 1132 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 1133 * @returns {Object} 1134 */ 1135 var validateChartConfig = function(config) { 1136 var message = ""; 1137 1138 if (!config.measures || config.measures.length == 0) 1139 { 1140 message = "No measure selected. Please select at lease one measure."; 1141 return {success: false, message: message}; 1142 } 1143 1144 if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1) 1145 { 1146 message = "Could not find x-axis in chart measure information."; 1147 return {success: false, message: message}; 1148 } 1149 1150 if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0) 1151 { 1152 message = "No " + studyNounSingular.toLowerCase() + " selected. " + 1153 "Please select at least one " + studyNounSingular.toLowerCase() + "."; 1154 return {success: false, message: message}; 1155 } 1156 1157 if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1) 1158 { 1159 message = "No group selected. Please select at least one group."; 1160 return {success: false, message: message}; 1161 } 1162 1163 if (generateSeriesList(config.measures).length == 0) 1164 { 1165 message = "No series or dimension selected. Please select at least one series/dimension value."; 1166 return {success: false, message: message}; 1167 } 1168 1169 if (!(config.displayIndividual || config.displayAggregate)) 1170 { 1171 message = "Please select either \"Show Individual Lines\" or \"Show Mean\"."; 1172 return {success: false, message: message}; 1173 } 1174 1175 // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects 1176 var subjectLength = config.subject.values ? config.subject.values.length : 0; 1177 if (config.displayIndividual && subjectLength > 10000) 1178 { 1179 message = "Unable to display individual series lines for greater than 10,000 total " + studyNounPlural.toLowerCase() + "."; 1180 return {success: false, message: message}; 1181 } 1182 1183 return {success: true, message: message}; 1184 }; 1185 1186 /** 1187 * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make 1188 * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean) 1189 * and a message parameter (string). If the success pararameter is false there is a critical error and the chart 1190 * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning 1191 * message if applicable. If message is not null and success is true, there is a warning. 1192 * @param {Object} data The data object, from getChartData. 1193 * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. 1194 * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. 1195 * @param {int} limit The data limit for a single report. 1196 * @returns {Object} 1197 */ 1198 var validateChartData = function(data, config, seriesList, limit) { 1199 var message = "", 1200 sep = "", 1201 msg = "", 1202 commaSep = "", 1203 noDataCounter = 0; 1204 1205 // warn the user if the data limit has been reached 1206 if ((data.individual && data.individual.rows.length == limit) || (data.aggregate && data.aggregate.rows.length == limit)) 1207 { 1208 message += sep + "The data limit for plotting has been reached. Consider filtering your data."; 1209 sep = "<br/>"; 1210 } 1211 1212 // for date based charts, give error message if there are no calculated interval values 1213 if (!data.hasIntervalData) 1214 { 1215 message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'."; 1216 sep = "<br/>"; 1217 } 1218 1219 // check to see if any of the measures don't have data 1220 Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) { 1221 if (!value) 1222 { 1223 noDataCounter++; 1224 msg += commaSep + key; 1225 commaSep = ", "; 1226 } 1227 }, this); 1228 if (msg.length > 0) 1229 { 1230 msg = "No data found for the following measures/dimensions: " + msg; 1231 1232 // if there is no data for any series, add to explanation 1233 if (noDataCounter == seriesList.length) 1234 { 1235 var isDateBased = config && config.measures[0].time == "date"; 1236 if (isDateBased) 1237 msg += ". This may be the result of a missing start date value for the selected subject(s)."; 1238 } 1239 1240 message += sep + msg; 1241 sep = "<br/>"; 1242 } 1243 1244 // check to make sure that data can be used in a log scale (if applicable) 1245 if (config) 1246 { 1247 var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); 1248 var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); 1249 1250 Ext4.each(config.measures, function(md){ 1251 var m = md.measure; 1252 1253 // check the left y-axis 1254 if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log" 1255 && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name]) 1256 || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name]))) 1257 { 1258 config.axis[leftAxisIndex].scale = "linear"; 1259 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."; 1260 sep = "<br/>"; 1261 } 1262 1263 // check the right y-axis 1264 if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log" 1265 && ((data.individual && data.individual.hasNegativeValues[m.name]) 1266 || (data.aggregate && data.aggregate.hasNegativeValues[m.name]))) 1267 { 1268 config.axis[rightAxisIndex].scale = "linear"; 1269 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."; 1270 sep = "<br/>"; 1271 } 1272 1273 }); 1274 } 1275 1276 return {success: true, message: message}; 1277 }; 1278 1279 return { 1280 /** 1281 * Loads all of the required dependencies for a Time Chart. 1282 * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. 1283 * @param {Object} scope The scope to be used when executing the callback. 1284 */ 1285 loadVisDependencies: LABKEY.requiresVisualization, 1286 generateAcrossChartAxisRanges : generateAcrossChartAxisRanges, 1287 generateAes : generateAes, 1288 generateApplyClipRect : generateApplyClipRect, 1289 generateIntervalKey : generateIntervalKey, 1290 generateLabels : generateLabels, 1291 generateLayers : generateLayers, 1292 generatePlotConfigs : generatePlotConfigs, 1293 generateScales : generateScales, 1294 generateSeriesList : generateSeriesList, 1295 generateTickMap : generateTickMap, 1296 generateNumberFormats : generateNumberFormats, 1297 getAxisIndex : getAxisIndex, 1298 getChartData : getChartData, 1299 validateChartConfig : validateChartConfig, 1300 validateChartData : validateChartData 1301 }; 1302 }; 1303