diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index 11fb334b3..c12253fe5 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -330,17 +330,23 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } + // ReferenceRangeSeries is used to separate series. Default to "InRange" and only + // promote to "GuideSet" on a match, so that a later non-matching guide set in the + // map cannot clobber the label back to "InRange" when multiple guide sets exist. + plotData['ReferenceRangeSeries'] = "InRange"; Ext4.Object.each(this.guideSetDataMap, function(guideSetId, guideSetData) { + // guideSetDataMap is keyed by guide set ID, so the iteration key guideSetId is a + // String (JS object keys are always strings), whereas plotData.guideSetId is a Number + // (set from the numeric server value in QCPlotHelperWrapper.processPlotDataRow). + // Parse the String key to an int so this is a type-safe === comparison and we don't + // rely on == coercion (mirrors the parseInt pattern in QCTrendPlotPanel.js). + const guideSetIdInt = parseInt(guideSetId, 10); // for truncating out of range guideset data find first index of plotDate ending at guideset.trainingEnd - if (plotData.guideSetId === guideSetId && plotData.inGuideSetTrainingRange && guideSetData.TrainingEnd <= this.startDate) { + if (plotData.guideSetId === guideSetIdInt && plotData.inGuideSetTrainingRange && guideSetData.TrainingEnd <= this.startDate) { this.filterPoints[frag][plotData.MetricId]['filterPointsFirstIndex'] = j + 1; - // ReferenceRangeSeries is used to separate series plotData['ReferenceRangeSeries'] = "GuideSet"; + return false; // stop once the matching guide set is found } - else { - plotData['ReferenceRangeSeries'] = "InRange"; - } - }, this); // for truncating out of range guideset data find last index of plotData starting from this.startDate @@ -365,20 +371,25 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { if (this.showExpRunRange && this.filterPoints) { for (let i = 0; i < plotDataRows.length; i++) { - Ext4.Object.each(this.filterPoints[plotDataRows[i].SeriesLabel], function (metricId, filterPointsData) { + const seriesPoints = this.filterPoints && this.filterPoints[plotDataRows[i].SeriesLabel]; + if (!seriesPoints) { + continue; + } + Ext4.Object.each(seriesPoints, function (metricId, filterPointsData) { // no need to filter if less than 6 data points are present between reference end of guideset and startdate if (filterPointsData['filterPointsFirstIndex'] && filterPointsData['filterPointsLastIndex']) { if (filterPointsData['filterPointsLastIndex'] - filterPointsData['filterPointsFirstIndex'] < 6) { - this.filterQCPoints = false; - // set the startDate field = acquired time of the 1st point of 5 points before the experiment run range - - this.getStartDateField().setValue(this.formatDate(plotDataRows[i].data[filterPointsData['filterPointsFirstIndex']].AcquiredTime)); + // Fewer than 6 out-of-range points for this series/metric, so there is nothing to truncate + // for it. Flag only this entry rather than clearing the global this.filterQCPoints, so that + // other series still truncate and the separator / guide-set line break still render. + filterPointsData['skipTruncation'] = true; + // set the startDate field = acquired time of the point right before the experiment run range + this.setStartDateFromFilterIndex(plotDataRows[i], filterPointsData['filterPointsFirstIndex']); } else { // skip 5 points filterPointsData['filterPointsLastIndex'] = filterPointsData['filterPointsLastIndex'] - 6; - // set the startDate field = acquired time of the 1st point of 5 points before the experiment run range - // adding 1 as the point is right after filter last index - this.getStartDateField().setValue(this.formatDate(plotDataRows[i].data[filterPointsData['filterPointsLastIndex'] + 1].AcquiredTime)); + // set the startDate field = acquired time of the point right after the new filter last index + this.setStartDateFromFilterIndex(plotDataRows[i], filterPointsData['filterPointsLastIndex'] + 1); } } }, this); @@ -389,6 +400,42 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { this.renderPlots(); }, + // Sets the start-date form field from the AcquiredTime of a point identified by a filterPoints index. + // The filterPoints indices (filterPointsFirstIndex / filterPointsLastIndex) are computed against + // this.fragmentPlotData[label].data, which has injected type:'missing' placeholder entries spliced in + // (see the "add any missing dates" block in processPlotData). plotDataRow.data is the raw server array: + // it has no missing entries and is the only place AcquiredTime is available, so a fragmentPlotData-space + // index cannot be used against it directly. Translate the index to raw-space by counting the non-missing + // entries before it, and guard the lookup so a stale/out-of-range index can never throw. + setStartDateFromFilterIndex: function(plotDataRow, fragIndex) { + if (!plotDataRow || fragIndex == null) { + return; + } + const fragData = this.fragmentPlotData[plotDataRow.SeriesLabel] && this.fragmentPlotData[plotDataRow.SeriesLabel].data; + if (!fragData || fragData.length === 0) { + return; + } + // Walk back to the nearest real (non-missing) entry at or before the requested index + let idx = Math.min(fragIndex, fragData.length - 1); + while (idx >= 0 && fragData[idx] && fragData[idx].type === 'missing') { + idx--; + } + if (idx < 0) { + return; + } + // raw index = number of non-missing entries strictly before idx (missing entries exist only in fragData) + let rawIndex = 0; + for (let k = 0; k < idx; k++) { + if (!fragData[k] || fragData[k].type !== 'missing') { + rawIndex++; + } + } + const rawPoint = plotDataRow.data[rawIndex]; + if (rawPoint && rawPoint.AcquiredTime) { + this.getStartDateField().setValue(this.formatDate(rawPoint.AcquiredTime)); + } + }, + renderPlots: function() { if (this.filterQCPoints) { this.truncateOutOfRangeQCPoints(); @@ -434,7 +481,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { Ext4.Object.each(this.fragmentPlotData, function(label, fragmentData) { // traverse plotData backwards from firstIndex to lastIndex and // remove them from the array - if (this.filterQCPoints && this.filterPoints) { + if (this.filterQCPoints && this.filterPoints && this.filterPoints[label]) { // when we're plotting two different metrics at the same time, then we // have repeated dates (from oldest to newest for metric 1, and then oldest to newest for metric 2, all in the same array). @@ -443,6 +490,9 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { const lab = label; filterPointsReversed.forEach(metricId => { + if (this.filterPoints[lab][metricId]['skipTruncation']) { + return; // too few out-of-range points for this series/metric to truncate + } let firstIndex = this.filterPoints[lab][metricId]['filterPointsFirstIndex']; let lastIndex = this.filterPoints[lab][metricId]['filterPointsLastIndex']; diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 90b25a755..bcb8de8ff 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -2325,8 +2325,10 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { .attr('stroke', color).attr('stroke-opacity', 0.1) .attr('fill', color).attr('fill-opacity', 0.1) .append("title") - .text(function (d) { - return "Selected replicate: " + Ext4.String.htmlEncode(plot.data[d.EndIndex].ReplicateName); + .text(function () { + // 'data' is the already-matched point for this replicate, don't index plot.data by + // seqValue (EndIndex), which breaks once out-of-range points have been truncated. + return "Selected replicate: " + Ext4.String.htmlEncode(data.ReplicateName); }); this.sendSvgElementToBack(plot, outlierRect); @@ -2399,8 +2401,18 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { var pointsData = precursorInfo.data; var expDataArr = []; - for (var i = startIndex; i <= endIndex; i++) { - expDataArr.push(pointsData[i].value); + // startIndex/endIndex are seqValues (x-axis positions), not array indices. Once out-of-range + // points are truncated, the array positions no longer line up with seqValue, so collect the + // experiment-range values by matching seqValue rather than indexing pointsData directly. + // In multi-series mode pointsData holds both metrics' points (the same seqValues repeated), so + // restrict to the primary metric - otherwise the experiment-range mean/std-dev/%CV would blend + // values from two different metrics into a single, meaningless statistic. + for (var i = 0; i < pointsData.length; i++) { + if (pointsData[i].seqValue >= startIndex && pointsData[i].seqValue <= endIndex + && pointsData[i].MetricId === this.metric + && pointsData[i].value !== undefined && pointsData[i].value !== null) { + expDataArr.push(pointsData[i].value); + } } var expMean = LABKEY.targetedms.PlotSettingsUtil.formatNumeric(LABKEY.vis.Stat.getMean(expDataArr));