From 7befff40808f9ba9b3085021232371d760778d17 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 14 Apr 2026 16:55:41 -0700 Subject: [PATCH 01/18] sort precursor in combined qc plots legend by precursor row ID --- src/org/labkey/targetedms/model/QCPlotFragment.java | 13 +++++++++++++ .../targetedms/outliers/OutlierGenerator.java | 11 +++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/org/labkey/targetedms/model/QCPlotFragment.java b/src/org/labkey/targetedms/model/QCPlotFragment.java index f03f47b8c..8fd2c5705 100644 --- a/src/org/labkey/targetedms/model/QCPlotFragment.java +++ b/src/org/labkey/targetedms/model/QCPlotFragment.java @@ -21,6 +21,8 @@ public class QCPlotFragment private List guideSetStats; @Nullable private Color _seriesColor; + @Nullable + private Long precursorRowId; public String getSeriesLabel() { @@ -205,4 +207,15 @@ public Color getSeriesColor() { return _seriesColor; } + + @Nullable + public Long getPrecursorRowId() + { + return precursorRowId; + } + + public void setPrecursorRowId(Long precursorRowId) + { + this.precursorRowId = precursorRowId; + } } diff --git a/src/org/labkey/targetedms/outliers/OutlierGenerator.java b/src/org/labkey/targetedms/outliers/OutlierGenerator.java index e133136ea..7a0061f06 100644 --- a/src/org/labkey/targetedms/outliers/OutlierGenerator.java +++ b/src/org/labkey/targetedms/outliers/OutlierGenerator.java @@ -539,7 +539,11 @@ public List getQCPlotFragment(List rawMetricDa Optional bestPrecursorIdRow = entry.getValue().stream().filter(x -> x.getPrecursorId() != null).min(Comparator.comparing(RawMetricDataSet::getPrecursorId)); // Remember the precursor ID so that we can assign a series color based on Skyline's algorithm - bestPrecursorIdRow.ifPresent(rawMetricDataSet -> fragmentsByPrecursorId.put(rawMetricDataSet.getPrecursorId(), qcPlotFragment)); + // and to sort by Skyline document order (row ID) instead of alphabetically + bestPrecursorIdRow.ifPresent(rawMetricDataSet -> { + fragmentsByPrecursorId.put(rawMetricDataSet.getPrecursorId(), qcPlotFragment); + qcPlotFragment.setPrecursorRowId(rawMetricDataSet.getPrecursorId()); + }); qcPlotFragment.setSeriesLabel(entry.getKey()); qcPlotFragment.setQcPlotData(entry.getValue()); @@ -591,7 +595,10 @@ public List getQCPlotFragment(List rawMetricDa } } - qcPlotFragments.sort(Comparator.comparing(QCPlotFragment::getSeriesLabel)); + // Sort by precursor row ID to preserve Skyline document order. Fragments with no precursor ID + // (e.g. trace metrics) fall back to alphabetical order after all precursor-scoped series. + qcPlotFragments.sort(Comparator.comparing(QCPlotFragment::getPrecursorRowId, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(QCPlotFragment::getSeriesLabel)); return qcPlotFragments; } From 2ca7e4fffc754383836f115f6fefc3b205fdd5b1 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 14 Apr 2026 17:43:33 -0700 Subject: [PATCH 02/18] Let users toggle a precursor's series by clicking on its color swatch --- webapp/TargetedMS/js/QCPlotHelperBase.js | 2 + webapp/TargetedMS/js/QCTrendPlotPanel.js | 58 ++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index c4b7c7dc3..a533ee9e3 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -822,6 +822,8 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { const plot = LABKEY.vis.TrendingLinePlot(plotConfig); plot.render(); + this.attachCombinedLegendClickHandlers(); + this.addAnnotationsToPlot(plot, combinePlotData); this.addGuideSetTrainingRangeToPlot(plot, combinePlotData); diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 529153071..c06bf3837 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -77,6 +77,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { enableBrushing: false, havePlotOptionsChanged: false, selectedAnnotations: {}, + hiddenPrecursorSeries: null, // Plain object mapping fragment label -> true when hidden in the combined plot runs: null, trailingRuns: null, minWidth: 1250, // Keep in sync with the width defined in qcTrendPlot.jsp @@ -1612,12 +1613,17 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { }, plotPointMouseOut : function(event, row, layerSel, valueName, plotConfig) { + let hidden = this.hiddenPrecursorSeries || {}; d3.selectAll('.point path').attr('fill-opacity', 1).attr('stroke-opacity', 1); d3.selectAll('path.line').attr('fill-opacity', 1).attr('stroke-opacity', 1); - d3.selectAll('.legend .legend-item').attr('fill-opacity', 1).attr('stroke-opacity', 1); + d3.selectAll('.legend .legend-item').each(function(d) { + var opacity = (d && d.name && !d.separator && hidden[d.hoverText || d.name.split('|')[0]]) ? 0.3 : 1; + d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); + }); }, highlightFragmentSeries : function(fragment) { + let hidden = this.hiddenPrecursorSeries || {}; var points = d3.selectAll('.point path'); var pointOpacityAcc = function(d) { return d.fragment === undefined || d.fragment === null || d.fragment === fragment ? 1 : 0.1 }; points.attr('fill-opacity', pointOpacityAcc).attr('stroke-opacity', pointOpacityAcc); @@ -1627,14 +1633,58 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { var lineOpacityAcc = function(d) { return d.group === undefined || d.group === null || d.group.indexOf(fragment + (hasYRightMetric ? '|' : '')) === 0 ? 1 : 0.1 }; lines.attr('fill-opacity', lineOpacityAcc).attr('stroke-opacity', lineOpacityAcc); - var legendItems = d3.selectAll('.legend .legend-item'); - var legendOpacityAcc = function(d) { + let legendItems = d3.selectAll('.legend .legend-item'); + let legendOpacityAcc = function(d) { if (!d.name) return 1; - return d.name.indexOf(fragment + (hasYRightMetric ? '|' : '')) === 0 ? 1 : 0.1 + let frag = d.hoverText || d.name.split('|')[0]; + if (hidden[frag]) return 0.3; // keep hidden series dimmed during hover + return d.name.indexOf(fragment + (hasYRightMetric ? '|' : '')) === 0 ? 1 : 0.1; }; legendItems.attr('fill-opacity', legendOpacityAcc).attr('stroke-opacity', legendOpacityAcc); }, + // Let users toggle a precursor's series by clicking on its color swatch. + toggleCombinedSeriesVisibility: function(fragment) { + if (!this.hiddenPrecursorSeries) { + this.hiddenPrecursorSeries = {}; + } + this.hiddenPrecursorSeries[fragment] = !this.hiddenPrecursorSeries[fragment]; + this.applySeriesVisibility(); + }, + + applySeriesVisibility: function() { + let hidden = this.hiddenPrecursorSeries || {}; + + d3.selectAll('.point path').attr('display', function(d) { + return (d && d.fragment && hidden[d.fragment]) ? 'none' : null; + }); + + d3.selectAll('path.line').attr('display', function(d) { + if (!d || !d.group) return null; + return hidden[d.group.split('|')[0]] ? 'none' : null; + }); + + d3.selectAll('.legend .legend-item').each(function(d) { + if (!d || !d.name || d.separator) return; + var opacity = hidden[d.hoverText || d.name.split('|')[0]] ? 0.3 : 1; + d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); + }); + }, + + attachCombinedLegendClickHandlers: function() { + let me = this; + d3.selectAll('.legend .legend-item').each(function(d) { + if (!d || !d.name || d.separator) return; + d3.select(this) + .style('cursor', 'pointer') + .on('click.toggleSeries', function(d) { + d3.event.stopPropagation(); + me.toggleCombinedSeriesVisibility(d.hoverText || d.name.split('|')[0]); + }); + }); + this.applySeriesVisibility(); + }, + plotBrushStartEvent : function(plot) { this.clearPlotBrush(plot); }, From 4998b336d3416a64043da8b56ac75e519d2b90f1 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 15 Apr 2026 09:24:44 -0700 Subject: [PATCH 03/18] Switch the legend to a tree that shows the protein/molecule list as a heading --- .../targetedms/TargetedMSController.java | 13 ++ .../targetedms/model/QCPlotFragment.java | 31 ++++ .../targetedms/outliers/OutlierGenerator.java | 10 ++ webapp/TargetedMS/css/qcTrendPlotReport.css | 14 ++ webapp/TargetedMS/js/QCPlotHelperBase.js | 2 + webapp/TargetedMS/js/QCPlotHelperWrapper.js | 11 ++ webapp/TargetedMS/js/QCTrendPlotPanel.js | 159 +++++++++++++++++- 7 files changed, 239 insertions(+), 1 deletion(-) diff --git a/src/org/labkey/targetedms/TargetedMSController.java b/src/org/labkey/targetedms/TargetedMSController.java index 952c0285c..6acf8c302 100644 --- a/src/org/labkey/targetedms/TargetedMSController.java +++ b/src/org/labkey/targetedms/TargetedMSController.java @@ -874,6 +874,7 @@ public static class LeveyJenningsPlotOptions private Integer _trailingRuns; private Integer _calendarMonthsToShow; private String _heatmapDataSource; + private String _hiddenSeries; public Map getAsMapOfStrings() { @@ -906,6 +907,8 @@ public Map getAsMapOfStrings() valueMap.put("calendarMonthsToShow", Integer.toString(_calendarMonthsToShow)); if (_heatmapDataSource != null) valueMap.put("heatMapDataSource", _heatmapDataSource); + if (_hiddenSeries != null) + valueMap.put("hiddenSeries", _hiddenSeries); // note: start and end date handled separately since they can be null and we want to persist that return valueMap; } @@ -1034,6 +1037,16 @@ public void setHeatmapDataSource(String heatmapDataSource) { _heatmapDataSource = heatmapDataSource; } + + public String getHiddenSeries() + { + return _hiddenSeries; + } + + public void setHiddenSeries(String hiddenSeries) + { + _hiddenSeries = hiddenSeries; + } } @RequiresPermission(ReadPermission.class) diff --git a/src/org/labkey/targetedms/model/QCPlotFragment.java b/src/org/labkey/targetedms/model/QCPlotFragment.java index 8fd2c5705..176ecb8a7 100644 --- a/src/org/labkey/targetedms/model/QCPlotFragment.java +++ b/src/org/labkey/targetedms/model/QCPlotFragment.java @@ -23,6 +23,10 @@ public class QCPlotFragment private Color _seriesColor; @Nullable private Long precursorRowId; + @Nullable + private Long peptideGroupId; + @Nullable + private String peptideGroupLabel; public String getSeriesLabel() { @@ -79,6 +83,11 @@ public JSONObject toJSON(boolean includeLJ, boolean includeMR, boolean includeMe JSONObject jsonObject = new JSONObject(); jsonObject.put("DataType", getDataType()); jsonObject.put("SeriesLabel", getSeriesLabel()); + if (peptideGroupId != null) + { + jsonObject.put("PeptideGroupId", peptideGroupId); + jsonObject.put("PeptideGroupLabel", peptideGroupLabel != null ? peptideGroupLabel : ""); + } if (_seriesColor != null) { jsonObject.put("SeriesColor", "#" + Integer.toHexString(_seriesColor.getRGB()).substring(2).toUpperCase()); @@ -218,4 +227,26 @@ public void setPrecursorRowId(Long precursorRowId) { this.precursorRowId = precursorRowId; } + + @Nullable + public Long getPeptideGroupId() + { + return peptideGroupId; + } + + public void setPeptideGroupId(Long peptideGroupId) + { + this.peptideGroupId = peptideGroupId; + } + + @Nullable + public String getPeptideGroupLabel() + { + return peptideGroupLabel; + } + + public void setPeptideGroupLabel(String peptideGroupLabel) + { + this.peptideGroupLabel = peptideGroupLabel; + } } diff --git a/src/org/labkey/targetedms/outliers/OutlierGenerator.java b/src/org/labkey/targetedms/outliers/OutlierGenerator.java index 7a0061f06..89d1e6ed1 100644 --- a/src/org/labkey/targetedms/outliers/OutlierGenerator.java +++ b/src/org/labkey/targetedms/outliers/OutlierGenerator.java @@ -44,9 +44,11 @@ import org.labkey.targetedms.model.SampleFileQCMetadata; import org.labkey.targetedms.parser.GeneralMolecule; import org.labkey.targetedms.parser.GeneralPrecursor; +import org.labkey.targetedms.parser.PeptideGroup; import org.labkey.targetedms.parser.SampleFile; import org.labkey.targetedms.query.MoleculeManager; import org.labkey.targetedms.query.MoleculePrecursorManager; +import org.labkey.targetedms.query.PeptideGroupManager; import org.labkey.targetedms.query.PeptideManager; import org.labkey.targetedms.query.PrecursorManager; @@ -592,6 +594,14 @@ public List getQCPlotFragment(List rawMetricDa Color color = ColorGenerator.getColor(molecule.getTextId(), seriesColors); entry.getValue().setSeriesColor(color); seriesColors.add(color); + + // set the peptide group (protein / molecule list) for the combined plot tree legend + PeptideGroup peptideGroup = PeptideGroupManager.getPeptideGroup(c, molecule.getPeptideGroupId()); + if (peptideGroup != null) + { + entry.getValue().setPeptideGroupId(peptideGroup.getId()); + entry.getValue().setPeptideGroupLabel(peptideGroup.getLabel()); + } } } diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css index 80aab9dfd..70b1bd527 100644 --- a/webapp/TargetedMS/css/qcTrendPlotReport.css +++ b/webapp/TargetedMS/css/qcTrendPlotReport.css @@ -102,4 +102,18 @@ .x4-panel-header-text-container-default { font-weight: normal; +} + +/* Combined plot tree legend (shown when document has multiple proteins / molecule lists) */ +.qc-combined-tree-legend { + background: white; + line-height: 1.4; +} + +.qc-combined-tree-legend .qc-tree-group label:hover { + background-color: #f0f0f0; +} + +.qc-combined-tree-legend .qc-tree-precursor:hover { + background-color: #f0f0f0; } \ No newline at end of file diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index a533ee9e3..b78d6aa17 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -412,9 +412,11 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } if (this.singlePlot && this.getMetricPropsById(this.metric).precursorScoped) { + this.peptideGroups = this.buildPeptideGroups(); addedPlot = this.addCombinedPeptideSinglePlot(metricProps); } else { + this.peptideGroups = null; addedPlot = this.addIndividualPrecursorPlots(metricProps); } diff --git a/webapp/TargetedMS/js/QCPlotHelperWrapper.js b/webapp/TargetedMS/js/QCPlotHelperWrapper.js index 686efef74..25e3a0bca 100644 --- a/webapp/TargetedMS/js/QCPlotHelperWrapper.js +++ b/webapp/TargetedMS/js/QCPlotHelperWrapper.js @@ -215,6 +215,11 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperWrapper", { } this.setPlotBrushingDisplayStyle(); + + if (this.hasPeptideGroupTree()) { + this.renderCombinedTreeLegend(id, legendMargin); + } + return true; }, @@ -295,6 +300,12 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperWrapper", { this.fragmentPlotData[fragment] = this.getInitFragmentPlotData(fragment, dataType, mz, color); } + // Store peptide group info (protein / molecule list) for the combined plot tree legend + if (this.fragmentPlotData[fragment].peptideGroupId == null && plotDataRow['PeptideGroupId'] != null) { + this.fragmentPlotData[fragment].peptideGroupId = plotDataRow['PeptideGroupId']; + this.fragmentPlotData[fragment].peptideGroupLabel = plotDataRow['PeptideGroupLabel']; + } + var metricId = row['MetricId']; const metricProp = metricProps[metricId]; diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index c06bf3837..65f74f7e6 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -148,6 +148,17 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { } initValues[key] = annotations; } + else if (key === 'hiddenSeries' && value) { + try { + var hiddenArr = JSON.parse(value); + var hiddenMap = {}; + if (Array.isArray(hiddenArr)) { + hiddenArr.forEach(function(f) { hiddenMap[f] = true; }); + } + initValues['hiddenPrecursorSeries'] = hiddenMap; + } + catch (e) { /* ignore malformed stored value */ } + } else { initValues[key] = value; } @@ -1650,6 +1661,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { } this.hiddenPrecursorSeries[fragment] = !this.hiddenPrecursorSeries[fragment]; this.applySeriesVisibility(); + this.havePlotOptionsChanged = true; + this.persistSelectedFormOptions(); }, applySeriesVisibility: function() { @@ -1669,6 +1682,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { var opacity = hidden[d.hoverText || d.name.split('|')[0]] ? 0.3 : 1; d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); }); + + this.updateTreeLegendState(); }, attachCombinedLegendClickHandlers: function() { @@ -1685,6 +1700,143 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { this.applySeriesVisibility(); }, + // Build an ordered list of protein/molecule-list groups from fragmentPlotData for the combined plot. + buildPeptideGroups: function() { + let groups = {}; + for (var i = 0; i < this.precursors.length; i++) { + let fragment = this.precursors[i]; + let info = this.fragmentPlotData[fragment]; + if (!info || info.peptideGroupId == null) continue; + let gid = info.peptideGroupId; + if (!groups[gid]) { + groups[gid] = { id: gid, label: info.peptideGroupLabel || '', fragments: [] }; + } + groups[gid].fragments.push(fragment); + } + let groupArray = Object.values(groups); + groupArray.sort(function(a, b) { return a.id - b.id; }); + return groupArray; + }, + + hasPeptideGroupTree: function() { + return !!this.peptideGroups && this.peptideGroups.length > 1; + }, + + renderCombinedTreeLegend: function(firstPlotId, legendMargin) { + let existing = document.getElementById('qc-combined-tree-legend'); + if (existing) existing.parentNode.removeChild(existing); + + if (!this.hasPeptideGroupTree()) return; + + let plotEl = document.getElementById(firstPlotId); + if (!plotEl) return; + + plotEl.style.position = 'relative'; + + let treeDiv = document.createElement('div'); + treeDiv.id = 'qc-combined-tree-legend'; + treeDiv.className = 'qc-combined-tree-legend'; + treeDiv.style.cssText = [ + 'position: absolute', + 'right: 0', + 'top: 65px', + 'width: ' + legendMargin + 'px', + 'overflow-y: auto', + 'max-height: 430px', + 'font-size: 11px', + 'font-family: Roboto, arial, helvetica, sans-serif', + 'padding: 0 4px', + 'box-sizing: border-box' + ].join('; '); + + treeDiv.innerHTML = this.buildTreeLegendHTML(); + plotEl.appendChild(treeDiv); + this.attachTreeLegendHandlers(treeDiv); + }, + + buildTreeLegendHTML: function() { + let hidden = this.hiddenPrecursorSeries || {}; + let html = ''; + for (let g = 0; g < this.peptideGroups.length; g++) { + let group = this.peptideGroups[g]; + let allHidden = group.fragments.every(function(f) { return !!hidden[f]; }); + html += '
'; + html += ''; + html += '
'; + for (let p = 0; p < group.fragments.length; p++) { + let fragment = group.fragments[p]; + let info = this.fragmentPlotData[fragment]; + if (!info) continue; + let text = this.legendHelper.getLegendItemText(info); + let color = info.color || '#000000'; + let opacity = hidden[fragment] ? '0.3' : '1'; + html += '
'; + html += ''; + html += '' + Ext4.util.Format.htmlEncode(text) + ''; + html += '
'; + } + html += '
'; + } + return html; + }, + + attachTreeLegendHandlers: function(treeDiv) { + let me = this; + let hidden = this.hiddenPrecursorSeries || {}; + + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(el) { + el.addEventListener('click', function() { + me.toggleCombinedSeriesVisibility(el.getAttribute('data-fragment')); + }); + }); + + treeDiv.querySelectorAll('.qc-tree-group-check').forEach(function(checkbox) { + let groupIdx = parseInt(checkbox.getAttribute('data-group-idx')); + let group = me.peptideGroups[groupIdx]; + let someHidden = group.fragments.some(function(f) { return !!hidden[f]; }); + let allHidden = group.fragments.every(function(f) { return !!hidden[f]; }); + checkbox.indeterminate = someHidden && !allHidden; + + checkbox.addEventListener('change', function() { + if (!me.hiddenPrecursorSeries) me.hiddenPrecursorSeries = {}; + let shouldHide = !checkbox.checked; + group.fragments.forEach(function(f) { + if (shouldHide) { + me.hiddenPrecursorSeries[f] = true; + } else { + delete me.hiddenPrecursorSeries[f]; + } + }); + me.applySeriesVisibility(); + }); + }); + }, + + updateTreeLegendState: function() { + let treeDiv = document.getElementById('qc-combined-tree-legend'); + if (!treeDiv || !this.peptideGroups) return; + + let hidden = this.hiddenPrecursorSeries || {}; + let me = this; + + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(el) { + el.style.opacity = hidden[el.getAttribute('data-fragment')] ? '0.3' : '1'; + }); + + treeDiv.querySelectorAll('.qc-tree-group-check').forEach(function(checkbox) { + let groupIdx = parseInt(checkbox.getAttribute('data-group-idx')); + let group = me.peptideGroups[groupIdx]; + let allHidden = group.fragments.every(function(f) { return !!hidden[f]; }); + let someHidden = group.fragments.some(function(f) { return !!hidden[f]; }); + checkbox.checked = !allHidden; + checkbox.indeterminate = someHidden && !allHidden; + }); + }, + plotBrushStartEvent : function(plot) { this.clearPlotBrush(plot); }, @@ -2701,6 +2853,10 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { } }); + var hiddenSeriesArr = Object.keys(this.hiddenPrecursorSeries || {}).filter(function(k) { + return !!this.hiddenPrecursorSeries[k]; + }, this); + var props = { metric: this.metric, metric2: this.metric2, @@ -2712,7 +2868,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { dateRangeOffset: this.dateRangeOffset, selectedAnnotations: annotationsProp, showExcludedPrecursors: this.showExcludedPrecursors, - trailingRuns: this.trailingRuns + trailingRuns: this.trailingRuns, + hiddenSeries: JSON.stringify(hiddenSeriesArr) }; // set start and end date to null unless we are From d36187386989d4e7476cb6162c16e11d36afb9cc Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 15 Apr 2026 09:33:30 -0700 Subject: [PATCH 04/18] persist the top level peptide checkbox state --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 65f74f7e6..d7fd05e30 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1812,6 +1812,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { } }); me.applySeriesVisibility(); + me.havePlotOptionsChanged = true; + me.persistSelectedFormOptions(); }); }); }, From bcca7293b77a6b184bb0e966d066154dc50900ee Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 15 Apr 2026 09:40:52 -0700 Subject: [PATCH 05/18] update comment --- webapp/TargetedMS/css/qcTrendPlotReport.css | 1 - webapp/TargetedMS/js/QCTrendPlotPanel.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css index 70b1bd527..b4434fed4 100644 --- a/webapp/TargetedMS/css/qcTrendPlotReport.css +++ b/webapp/TargetedMS/css/qcTrendPlotReport.css @@ -104,7 +104,6 @@ font-weight: normal; } -/* Combined plot tree legend (shown when document has multiple proteins / molecule lists) */ .qc-combined-tree-legend { background: white; line-height: 1.4; diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index d7fd05e30..a048cdf4b 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -77,7 +77,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { enableBrushing: false, havePlotOptionsChanged: false, selectedAnnotations: {}, - hiddenPrecursorSeries: null, // Plain object mapping fragment label -> true when hidden in the combined plot + hiddenPrecursorSeries: null, runs: null, trailingRuns: null, minWidth: 1250, // Keep in sync with the width defined in qcTrendPlot.jsp From 35203d74966b691b7592f2495ed9799927f72de6 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Fri, 24 Apr 2026 11:44:12 -0700 Subject: [PATCH 06/18] fix gaps bw peptides --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 63 ++++++++++++++++++------ 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index a048cdf4b..6577a73ac 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1733,16 +1733,20 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { plotEl.style.position = 'relative'; + let legendTop = 65 + this.getMaxStackedAnnotations() * 12; + let plotHeight = this.singlePlot ? 500 : 300; + let maxHeight = plotHeight - legendTop - 10; + let treeDiv = document.createElement('div'); treeDiv.id = 'qc-combined-tree-legend'; treeDiv.className = 'qc-combined-tree-legend'; treeDiv.style.cssText = [ 'position: absolute', 'right: 0', - 'top: 65px', + 'top: ' + legendTop + 'px', 'width: ' + legendMargin + 'px', + 'max-height: ' + Math.max(50, maxHeight) + 'px', 'overflow-y: auto', - 'max-height: 430px', 'font-size: 11px', 'font-family: Roboto, arial, helvetica, sans-serif', 'padding: 0 4px', @@ -1752,20 +1756,36 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { treeDiv.innerHTML = this.buildTreeLegendHTML(); plotEl.appendChild(treeDiv); this.attachTreeLegendHandlers(treeDiv); + + d3.selectAll('[id^="combinedPlot"] .legend').style('display', 'none'); }, buildTreeLegendHTML: function() { let hidden = this.hiddenPrecursorSeries || {}; let html = ''; + + let peptideGroups = []; + let ionGroups = []; for (let g = 0; g < this.peptideGroups.length; g++) { let group = this.peptideGroups[g]; + let firstInfo = this.fragmentPlotData[group.fragments[0]]; + if (firstInfo && firstInfo.dataType === 'Peptide') { + peptideGroups.push({group: group, idx: g}); + } else { + ionGroups.push({group: group, idx: g}); + } + } + + let sectionHeaderStyle = 'font-weight: bold; font-size: 11px; padding: 2px 0 3px; border-bottom: 1px solid #ccc; margin-bottom: 4px;'; + + let renderGroup = function(group, g) { let allHidden = group.fragments.every(function(f) { return !!hidden[f]; }); - html += '
'; - html += ''; - html += '
'; + let out = '
'; + out += ''; + out += '
'; for (let p = 0; p < group.fragments.length; p++) { let fragment = group.fragments[p]; let info = this.fragmentPlotData[fragment]; @@ -1773,14 +1793,29 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let text = this.legendHelper.getLegendItemText(info); let color = info.color || '#000000'; let opacity = hidden[fragment] ? '0.3' : '1'; - html += '
'; - html += ''; - html += '' + Ext4.util.Format.htmlEncode(text) + ''; - html += '
'; + out += '
'; + out += ''; + out += '' + Ext4.util.Format.htmlEncode(text) + ''; + out += '
'; + } + out += '
'; + return out; + }.bind(this); + + if (peptideGroups.length > 0) { + html += '
Peptides
'; + for (let i = 0; i < peptideGroups.length; i++) { + html += renderGroup(peptideGroups[i].group, peptideGroups[i].idx); } - html += '
'; } + if (ionGroups.length > 0) { + html += '
Ions
'; + for (let i = 0; i < ionGroups.length; i++) { + html += renderGroup(ionGroups[i].group, ionGroups[i].idx); + } + } + return html; }, From 14d6349bc83bfc1ea709b31f7e7673514289f5b5 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 26 Apr 2026 07:18:27 -0700 Subject: [PATCH 07/18] highlight/dim peptide series and tree on hover over peptide group/name --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 6577a73ac..95d19a750 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1654,6 +1654,35 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { legendItems.attr('fill-opacity', legendOpacityAcc).attr('stroke-opacity', legendOpacityAcc); }, + highlightGroupSeries: function(fragments, treeDiv) { + let hidden = this.hiddenPrecursorSeries || {}; + let hasYRightMetric = this.metric2; + let fragmentSet = {}; + fragments.forEach(function(f) { fragmentSet[f] = true; }); + + d3.selectAll('.point path').attr('fill-opacity', function(d) { + return d.fragment === undefined || d.fragment === null || fragmentSet[d.fragment] ? 1 : 0.1; + }).attr('stroke-opacity', function(d) { + return d.fragment === undefined || d.fragment === null || fragmentSet[d.fragment] ? 1 : 0.1; + }); + + d3.selectAll('path.line').attr('fill-opacity', function(d) { + if (d.group === undefined || d.group === null) return 1; + return fragmentSet[d.group.split('|')[0]] ? 1 : 0.1; + }).attr('stroke-opacity', function(d) { + if (d.group === undefined || d.group === null) return 1; + return fragmentSet[d.group.split('|')[0]] ? 1 : 0.1; + }); + + if (treeDiv) { + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { + let itemFragment = item.getAttribute('data-fragment'); + let inGroup = fragmentSet[itemFragment]; + item.style.opacity = inGroup ? (hidden[itemFragment] ? '0.3' : '1') : (hidden[itemFragment] ? '0.03' : '0.1'); + }); + } + }, + // Let users toggle a precursor's series by clicking on its color swatch. toggleCombinedSeriesVisibility: function(fragment) { if (!this.hiddenPrecursorSeries) { @@ -1827,6 +1856,24 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { el.addEventListener('click', function() { me.toggleCombinedSeriesVisibility(el.getAttribute('data-fragment')); }); + + el.addEventListener('mouseenter', function() { + let fragment = el.getAttribute('data-fragment'); + me.highlightFragmentSeries(fragment); + let hidden = me.hiddenPrecursorSeries || {}; + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { + let itemFragment = item.getAttribute('data-fragment'); + item.style.opacity = itemFragment === fragment ? '1' : (hidden[itemFragment] ? '0.03' : '0.1'); + }); + }); + + el.addEventListener('mouseleave', function() { + me.plotPointMouseOut(); + let hidden = me.hiddenPrecursorSeries || {}; + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { + item.style.opacity = hidden[item.getAttribute('data-fragment')] ? '0.3' : '1'; + }); + }); }); treeDiv.querySelectorAll('.qc-tree-group-check').forEach(function(checkbox) { @@ -1836,6 +1883,20 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let allHidden = group.fragments.every(function(f) { return !!hidden[f]; }); checkbox.indeterminate = someHidden && !allHidden; + let label = checkbox.closest('label'); + if (label) { + label.addEventListener('mouseenter', function() { + me.highlightGroupSeries(group.fragments, treeDiv); + }); + label.addEventListener('mouseleave', function() { + me.plotPointMouseOut(); + let hidden = me.hiddenPrecursorSeries || {}; + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { + item.style.opacity = hidden[item.getAttribute('data-fragment')] ? '0.3' : '1'; + }); + }); + } + checkbox.addEventListener('change', function() { if (!me.hiddenPrecursorSeries) me.hiddenPrecursorSeries = {}; let shouldHide = !checkbox.checked; From 44424b6c0f93e0cf03c0c0b5a528dc2eccc2b986 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 26 Apr 2026 07:36:57 -0700 Subject: [PATCH 08/18] highlight/dim peptide series and tree on hover over qc plot series --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 44 ++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 95d19a750..06f50d76d 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1631,6 +1631,16 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { var opacity = (d && d.name && !d.separator && hidden[d.hoverText || d.name.split('|')[0]]) ? 0.3 : 1; d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); }); + + let treeDiv = document.getElementById('qc-combined-tree-legend'); + if (treeDiv) { + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { + item.style.opacity = hidden[item.getAttribute('data-fragment')] ? '0.3' : '1'; + }); + treeDiv.querySelectorAll('.qc-tree-group label').forEach(function(label) { + label.style.opacity = '1'; + }); + } }, highlightFragmentSeries : function(fragment) { @@ -1652,9 +1662,38 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { return d.name.indexOf(fragment + (hasYRightMetric ? '|' : '')) === 0 ? 1 : 0.1; }; legendItems.attr('fill-opacity', legendOpacityAcc).attr('stroke-opacity', legendOpacityAcc); + + let baseFragment = fragment.split('|')[0]; + let activeGroupIdx = this.getGroupIdxForFragment(baseFragment); + let treeDiv = document.getElementById('qc-combined-tree-legend'); + if (treeDiv) { + treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { + let itemFragment = item.getAttribute('data-fragment'); + item.style.opacity = itemFragment === baseFragment ? '1' : (hidden[itemFragment] ? '0.03' : '0.1'); + }); + this.applyTreeGroupLabelHighlight(treeDiv, activeGroupIdx); + } + }, + + getGroupIdxForFragment: function(fragment) { + if (!this.peptideGroups) return -1; + for (let g = 0; g < this.peptideGroups.length; g++) { + if (this.peptideGroups[g].fragments.indexOf(fragment) !== -1) return g; + } + return -1; + }, + + applyTreeGroupLabelHighlight: function(treeDiv, activeGroupIdx) { + treeDiv.querySelectorAll('.qc-tree-group').forEach(function(groupEl) { + let checkbox = groupEl.querySelector('.qc-tree-group-check'); + let label = groupEl.querySelector('label'); + if (checkbox && label) { + label.style.opacity = parseInt(checkbox.getAttribute('data-group-idx')) === activeGroupIdx ? '1' : '0.1'; + } + }); }, - highlightGroupSeries: function(fragments, treeDiv) { + highlightGroupSeries: function(fragments, groupIdx, treeDiv) { let hidden = this.hiddenPrecursorSeries || {}; let hasYRightMetric = this.metric2; let fragmentSet = {}; @@ -1680,6 +1719,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let inGroup = fragmentSet[itemFragment]; item.style.opacity = inGroup ? (hidden[itemFragment] ? '0.3' : '1') : (hidden[itemFragment] ? '0.03' : '0.1'); }); + this.applyTreeGroupLabelHighlight(treeDiv, groupIdx); } }, @@ -1886,7 +1926,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let label = checkbox.closest('label'); if (label) { label.addEventListener('mouseenter', function() { - me.highlightGroupSeries(group.fragments, treeDiv); + me.highlightGroupSeries(group.fragments, groupIdx, treeDiv); }); label.addEventListener('mouseleave', function() { me.plotPointMouseOut(); From c9c2aae6ddb152e5af4d333b553becc36b1d8cb1 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 26 Apr 2026 07:52:33 -0700 Subject: [PATCH 09/18] change the color rectangle to a colored checkbox to indicate that they can be turned on and off --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 25 +++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 06f50d76d..88aafff4b 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1829,6 +1829,18 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { d3.selectAll('[id^="combinedPlot"] .legend').style('display', 'none'); }, + makeColorCheckboxSVG: function(color, checked) { + if (checked) { + return '' + + '' + + '' + + ''; + } + return '' + + '' + + ''; + }, + buildTreeLegendHTML: function() { let hidden = this.hiddenPrecursorSeries || {}; let html = ''; @@ -1862,9 +1874,9 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let text = this.legendHelper.getLegendItemText(info); let color = info.color || '#000000'; let opacity = hidden[fragment] ? '0.3' : '1'; - out += '
'; - out += ''; + out += this.makeColorCheckboxSVG(color, !hidden[fragment]); out += '' + Ext4.util.Format.htmlEncode(text) + ''; out += '
'; } @@ -1962,7 +1974,14 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let me = this; treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(el) { - el.style.opacity = hidden[el.getAttribute('data-fragment')] ? '0.3' : '1'; + let fragment = el.getAttribute('data-fragment'); + let color = el.getAttribute('data-color'); + let isHidden = !!hidden[fragment]; + el.style.opacity = isHidden ? '0.5' : '1'; + let svg = el.querySelector('svg'); + if (svg && color) { + svg.outerHTML = me.makeColorCheckboxSVG(color, !isHidden); + } }); treeDiv.querySelectorAll('.qc-tree-group-check').forEach(function(checkbox) { From 435f72d5ff3aaa936abb75a2580d23961d2dd42b Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 26 Apr 2026 07:56:53 -0700 Subject: [PATCH 10/18] Change the group-level checkbox to grey --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 88aafff4b..4fa43fbfb 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1863,7 +1863,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let allHidden = group.fragments.every(function(f) { return !!hidden[f]; }); let out = '
'; out += ''; out += '
'; From d6a18036cc72e7e31d22d4500f5ec72e6da27716 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 26 Apr 2026 08:15:49 -0700 Subject: [PATCH 11/18] indent grouped items more so they line up with the group name text --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 35 ++++++++++++++++-------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 4fa43fbfb..cf281f641 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1866,7 +1866,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { out += ''; out += '' + Ext4.util.Format.htmlEncode(group.label || 'Unknown') + ''; out += ''; - out += '
'; + out += '
'; for (let p = 0; p < group.fragments.length; p++) { let fragment = group.fragments[p]; let info = this.fragmentPlotData[fragment]; @@ -1911,8 +1911,17 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { el.addEventListener('mouseenter', function() { let fragment = el.getAttribute('data-fragment'); - me.highlightFragmentSeries(fragment); let hidden = me.hiddenPrecursorSeries || {}; + if (hidden[fragment]) { + // Temporarily reveal this hidden series so hover shows it in the plot + d3.selectAll('.point path').each(function(d) { + if (d && d.fragment === fragment) d3.select(this).attr('display', null); + }); + d3.selectAll('path.line').each(function(d) { + if (d && d.group && d.group.split('|')[0] === fragment) d3.select(this).attr('display', null); + }); + } + me.highlightFragmentSeries(fragment); treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { let itemFragment = item.getAttribute('data-fragment'); item.style.opacity = itemFragment === fragment ? '1' : (hidden[itemFragment] ? '0.03' : '0.1'); @@ -1920,11 +1929,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { }); el.addEventListener('mouseleave', function() { - me.plotPointMouseOut(); - let hidden = me.hiddenPrecursorSeries || {}; - treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { - item.style.opacity = hidden[item.getAttribute('data-fragment')] ? '0.3' : '1'; - }); + me.applySeriesVisibility(); // re-applies display:none on hidden series + me.plotPointMouseOut(); // resets plot opacity and tree label opacity }); }); @@ -1938,14 +1944,21 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let label = checkbox.closest('label'); if (label) { label.addEventListener('mouseenter', function() { + let hidden = me.hiddenPrecursorSeries || {}; + let hiddenFragments = group.fragments.filter(function(f) { return !!hidden[f]; }); + if (hiddenFragments.length > 0) { + d3.selectAll('.point path').each(function(d) { + if (d && hiddenFragments.indexOf(d.fragment) !== -1) d3.select(this).attr('display', null); + }); + d3.selectAll('path.line').each(function(d) { + if (d && d.group && hiddenFragments.indexOf(d.group.split('|')[0]) !== -1) d3.select(this).attr('display', null); + }); + } me.highlightGroupSeries(group.fragments, groupIdx, treeDiv); }); label.addEventListener('mouseleave', function() { + me.applySeriesVisibility(); me.plotPointMouseOut(); - let hidden = me.hiddenPrecursorSeries || {}; - treeDiv.querySelectorAll('.qc-tree-precursor').forEach(function(item) { - item.style.opacity = hidden[item.getAttribute('data-fragment')] ? '0.3' : '1'; - }); }); } From 22b9cbeea385e3795a16ae1b63e80a3a23ab6256 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 26 Apr 2026 08:37:08 -0700 Subject: [PATCH 12/18] remove the gray background in tree legend on hover --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index cf281f641..8808cf069 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1806,6 +1806,16 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let plotHeight = this.singlePlot ? 500 : 300; let maxHeight = plotHeight - legendTop - 10; + if (!document.getElementById('qc-tree-legend-styles')) { + let style = document.createElement('style'); + style.id = 'qc-tree-legend-styles'; + style.textContent = + '#qc-combined-tree-legend .qc-tree-precursor:hover,' + + '#qc-combined-tree-legend .qc-tree-group label:hover' + + '{ background: none !important; }'; + document.head.appendChild(style); + } + let treeDiv = document.createElement('div'); treeDiv.id = 'qc-combined-tree-legend'; treeDiv.className = 'qc-combined-tree-legend'; From 04cfa451fb2137deed6f2663e7b0a6b6ad360ce8 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 29 Apr 2026 10:16:22 -0700 Subject: [PATCH 13/18] ident nicely --- webapp/TargetedMS/js/QCTrendPlotPanel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 8808cf069..b654b635d 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1873,10 +1873,10 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let allHidden = group.fragments.every(function(f) { return !!hidden[f]; }); let out = '
'; out += ''; - out += '
'; + out += '
'; for (let p = 0; p < group.fragments.length; p++) { let fragment = group.fragments[p]; let info = this.fragmentPlotData[fragment]; From 748c9a987b0357ef4e15b1495506a33d8cc3c7d0 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 29 Apr 2026 10:42:54 -0700 Subject: [PATCH 14/18] code review comments --- .../targetedms/outliers/OutlierGenerator.java | 3 ++- webapp/TargetedMS/js/QCPlotHelperBase.js | 9 ++++--- webapp/TargetedMS/js/QCTrendPlotPanel.js | 24 +++++++++---------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/org/labkey/targetedms/outliers/OutlierGenerator.java b/src/org/labkey/targetedms/outliers/OutlierGenerator.java index 89d1e6ed1..f2c42bbdb 100644 --- a/src/org/labkey/targetedms/outliers/OutlierGenerator.java +++ b/src/org/labkey/targetedms/outliers/OutlierGenerator.java @@ -565,6 +565,7 @@ public List getQCPlotFragment(List rawMetricDa // Now that we have all the precursor IDs, in order (important so that we de-dupe the colors in a stable order), // run through them and choose a color Set seriesColors = new HashSet<>(); + Map peptideGroupCache = new HashMap<>(); for (Map.Entry entry : fragmentsByPrecursorId.entrySet()) { long precursorId = entry.getKey(); @@ -596,7 +597,7 @@ public List getQCPlotFragment(List rawMetricDa seriesColors.add(color); // set the peptide group (protein / molecule list) for the combined plot tree legend - PeptideGroup peptideGroup = PeptideGroupManager.getPeptideGroup(c, molecule.getPeptideGroupId()); + PeptideGroup peptideGroup = peptideGroupCache.computeIfAbsent(molecule.getPeptideGroupId(), id -> PeptideGroupManager.getPeptideGroup(c, id)); if (peptideGroup != null) { entry.getValue().setPeptideGroupId(peptideGroup.getId()); diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index b78d6aa17..b99226ec8 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -8,7 +8,10 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { statics: { qcPlotTypes : ['Metric Value', 'Moving Range', 'CUSUMm', 'CUSUMv', 'Trailing CV', 'Trailing Mean'], maxPointsPerSeries : 300, - shapeDomain: ['Include', 'Exclude', 'Include-Outlier', 'Exclude-Outlier'] + shapeDomain: ['Include', 'Exclude', 'Include-Outlier', 'Exclude-Outlier'], + // Separates fragment from series name in legend item names (e.g. "PEPTIDE|Left"). Fragments are peptide + // sequences or molecule names and are not expected to contain this character. + SERIES_NAME_SEP: '|' }, showMetricValuePlot: function() { @@ -604,7 +607,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { const series1Legend = precursorInfo.dataType === 'Peptide' ? proteomicsLegend : ionLegend; series1Legend.push({ - name: precursorInfo.fragment + (this.isMultiSeries() ? '|' + legendSeries[0] : ''), + name: precursorInfo.fragment + (this.isMultiSeries() ? LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP + legendSeries[0] : ''), text: this.legendHelper.getLegendItemText(precursorInfo), hoverText: precursorInfo.fragment, color: groupColors[i % groupColors.length] @@ -630,7 +633,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { precursorInfo = this.fragmentPlotData[this.precursors[i]]; series2Legend.push({ - name: precursorInfo.fragment + '|' + legendSeries[1], + name: precursorInfo.fragment + LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP + legendSeries[1], text: this.legendHelper.getLegendItemText(precursorInfo), hoverText: precursorInfo.fragment, color: groupColors[(this.precursors.length + i) % groupColors.length] diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index b654b635d..c5d16bd9d 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1628,7 +1628,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { d3.selectAll('.point path').attr('fill-opacity', 1).attr('stroke-opacity', 1); d3.selectAll('path.line').attr('fill-opacity', 1).attr('stroke-opacity', 1); d3.selectAll('.legend .legend-item').each(function(d) { - var opacity = (d && d.name && !d.separator && hidden[d.hoverText || d.name.split('|')[0]]) ? 0.3 : 1; + var opacity = (d && d.name && !d.separator && hidden[d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]]) ? 0.3 : 1; d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); }); @@ -1651,19 +1651,19 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { var hasYRightMetric = this.metric2; var lines = d3.selectAll('path.line'); - var lineOpacityAcc = function(d) { return d.group === undefined || d.group === null || d.group.indexOf(fragment + (hasYRightMetric ? '|' : '')) === 0 ? 1 : 0.1 }; + var lineOpacityAcc = function(d) { return d.group === undefined || d.group === null || d.group.indexOf(fragment + (hasYRightMetric ? LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP : '')) === 0 ? 1 : 0.1 }; lines.attr('fill-opacity', lineOpacityAcc).attr('stroke-opacity', lineOpacityAcc); let legendItems = d3.selectAll('.legend .legend-item'); let legendOpacityAcc = function(d) { if (!d.name) return 1; - let frag = d.hoverText || d.name.split('|')[0]; + let frag = d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]; if (hidden[frag]) return 0.3; // keep hidden series dimmed during hover - return d.name.indexOf(fragment + (hasYRightMetric ? '|' : '')) === 0 ? 1 : 0.1; + return d.name.indexOf(fragment + (hasYRightMetric ? LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP : '')) === 0 ? 1 : 0.1; }; legendItems.attr('fill-opacity', legendOpacityAcc).attr('stroke-opacity', legendOpacityAcc); - let baseFragment = fragment.split('|')[0]; + let baseFragment = fragment.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]; let activeGroupIdx = this.getGroupIdxForFragment(baseFragment); let treeDiv = document.getElementById('qc-combined-tree-legend'); if (treeDiv) { @@ -1707,10 +1707,10 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { d3.selectAll('path.line').attr('fill-opacity', function(d) { if (d.group === undefined || d.group === null) return 1; - return fragmentSet[d.group.split('|')[0]] ? 1 : 0.1; + return fragmentSet[d.group.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]] ? 1 : 0.1; }).attr('stroke-opacity', function(d) { if (d.group === undefined || d.group === null) return 1; - return fragmentSet[d.group.split('|')[0]] ? 1 : 0.1; + return fragmentSet[d.group.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]] ? 1 : 0.1; }); if (treeDiv) { @@ -1743,12 +1743,12 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { d3.selectAll('path.line').attr('display', function(d) { if (!d || !d.group) return null; - return hidden[d.group.split('|')[0]] ? 'none' : null; + return hidden[d.group.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]] ? 'none' : null; }); d3.selectAll('.legend .legend-item').each(function(d) { if (!d || !d.name || d.separator) return; - var opacity = hidden[d.hoverText || d.name.split('|')[0]] ? 0.3 : 1; + var opacity = hidden[d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]] ? 0.3 : 1; d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); }); @@ -1763,7 +1763,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { .style('cursor', 'pointer') .on('click.toggleSeries', function(d) { d3.event.stopPropagation(); - me.toggleCombinedSeriesVisibility(d.hoverText || d.name.split('|')[0]); + me.toggleCombinedSeriesVisibility(d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]); }); }); this.applySeriesVisibility(); @@ -1928,7 +1928,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { if (d && d.fragment === fragment) d3.select(this).attr('display', null); }); d3.selectAll('path.line').each(function(d) { - if (d && d.group && d.group.split('|')[0] === fragment) d3.select(this).attr('display', null); + if (d && d.group && d.group.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0] === fragment) d3.select(this).attr('display', null); }); } me.highlightFragmentSeries(fragment); @@ -1961,7 +1961,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { if (d && hiddenFragments.indexOf(d.fragment) !== -1) d3.select(this).attr('display', null); }); d3.selectAll('path.line').each(function(d) { - if (d && d.group && hiddenFragments.indexOf(d.group.split('|')[0]) !== -1) d3.select(this).attr('display', null); + if (d && d.group && hiddenFragments.indexOf(d.group.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]) !== -1) d3.select(this).attr('display', null); }); } me.highlightGroupSeries(group.fragments, groupIdx, treeDiv); From 7e2811dfbd46a5cc4f644d9235dfcce2edf359ea Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 29 Apr 2026 11:45:32 -0700 Subject: [PATCH 15/18] make square shape consistent --- webapp/TargetedMS/js/QCPlotHelperBase.js | 10 ++++++--- webapp/TargetedMS/js/QCTrendPlotPanel.js | 27 +++++++++++++++--------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index b99226ec8..fdfe805e2 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -11,7 +11,9 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { shapeDomain: ['Include', 'Exclude', 'Include-Outlier', 'Exclude-Outlier'], // Separates fragment from series name in legend item names (e.g. "PEPTIDE|Left"). Fragments are peptide // sequences or molecule names and are not expected to contain this character. - SERIES_NAME_SEP: '|' + SERIES_NAME_SEP: '|', + // Square 12x12 shape for combined plot legend swatches, matching the tree legend checkbox size. + SQUARE_LEGEND_SHAPE: function() { return 'M-6,-6 L6,-6 6,6 -6,6 Z'; } }, showMetricValuePlot: function() { @@ -610,7 +612,8 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { name: precursorInfo.fragment + (this.isMultiSeries() ? LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP + legendSeries[0] : ''), text: this.legendHelper.getLegendItemText(precursorInfo), hoverText: precursorInfo.fragment, - color: groupColors[i % groupColors.length] + color: groupColors[i % groupColors.length], + shape: LABKEY.targetedms.QCPlotHelperBase.SQUARE_LEGEND_SHAPE }); } } @@ -636,7 +639,8 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { name: precursorInfo.fragment + LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP + legendSeries[1], text: this.legendHelper.getLegendItemText(precursorInfo), hoverText: precursorInfo.fragment, - color: groupColors[(this.precursors.length + i) % groupColors.length] + color: groupColors[(this.precursors.length + i) % groupColors.length], + shape: LABKEY.targetedms.QCPlotHelperBase.SQUARE_LEGEND_SHAPE }); } } diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index c5d16bd9d..8eb407e76 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1628,8 +1628,11 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { d3.selectAll('.point path').attr('fill-opacity', 1).attr('stroke-opacity', 1); d3.selectAll('path.line').attr('fill-opacity', 1).attr('stroke-opacity', 1); d3.selectAll('.legend .legend-item').each(function(d) { - var opacity = (d && d.name && !d.separator && hidden[d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]]) ? 0.3 : 1; - d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); + if (!d || !d.name || d.separator) return; + let isHidden = !!(hidden[d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]]); + let item = d3.select(this); + item.select('path').attr('fill', isHidden ? 'none' : (d.color || null)); + item.select('text').attr('opacity', isHidden ? 0.3 : 1); }); let treeDiv = document.getElementById('qc-combined-tree-legend'); @@ -1655,13 +1658,15 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { lines.attr('fill-opacity', lineOpacityAcc).attr('stroke-opacity', lineOpacityAcc); let legendItems = d3.selectAll('.legend .legend-item'); - let legendOpacityAcc = function(d) { - if (!d.name) return 1; + legendItems.each(function(d) { + if (!d.name) return; let frag = d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]; - if (hidden[frag]) return 0.3; // keep hidden series dimmed during hover - return d.name.indexOf(fragment + (hasYRightMetric ? LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP : '')) === 0 ? 1 : 0.1; - }; - legendItems.attr('fill-opacity', legendOpacityAcc).attr('stroke-opacity', legendOpacityAcc); + let isHidden = !!hidden[frag]; + let isActive = d.name.indexOf(fragment + (hasYRightMetric ? LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP : '')) === 0; + let item = d3.select(this); + item.select('path').attr('fill', isHidden ? 'none' : (d.color || null)); + item.select('text').attr('opacity', isHidden ? 0.1 : (isActive ? 1 : 0.1)); + }); let baseFragment = fragment.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]; let activeGroupIdx = this.getGroupIdxForFragment(baseFragment); @@ -1748,8 +1753,10 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { d3.selectAll('.legend .legend-item').each(function(d) { if (!d || !d.name || d.separator) return; - var opacity = hidden[d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]] ? 0.3 : 1; - d3.select(this).attr('fill-opacity', opacity).attr('stroke-opacity', opacity); + let isHidden = !!hidden[d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]]; + let item = d3.select(this); + item.select('path').attr('fill', isHidden ? 'none' : (d.color || null)); + item.select('text').attr('opacity', isHidden ? 0.3 : 1); }); this.updateTreeLegendState(); From f696c65c48d4e4eb6d877be6f660e530cc39804a Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 29 Apr 2026 12:11:21 -0700 Subject: [PATCH 16/18] fix exp range values for combined plots due to adjust for the new sort order - by precurson row id --- .../TargetedMSExperimentalQCLinkTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSExperimentalQCLinkTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSExperimentalQCLinkTest.java index fbdb5f84d..93c7c2f0d 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSExperimentalQCLinkTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSExperimentalQCLinkTest.java @@ -100,8 +100,8 @@ public void testLinkExperimentalQC() String expRange = "Skyline File: " + SProCoP_FILE + ", " + "Start: 2013-08-09 11:39:00, " + "End: 2013-08-27 14:45:49, " + - "Mean: 14.669, Std Dev: 0.501, " + - "%CV: 3.415"; + "Mean: 28.764, Std Dev: 0.666, " + + "%CV: 2.315"; goToProjectHome(QC_FOLDER_1); PanoramaDashboard qcDashboard = new PanoramaDashboard(this); @@ -114,16 +114,16 @@ public void testLinkExperimentalQC() "Start: 2013-08-03 00:00:00, " + "End: 2013-08-09 23:59:00, " + "# Runs: 5, " + - "Mean: 15.427, " + - "Std Dev: 0.973, " + - "%CV: 6.307", + "Mean: 32.798, " + + "Std Dev: 7.613, " + + "%CV: 23.212", "Guide Set ID: " + getGuideSetRowId("Second") + ", " + "Start: 2013-08-19 00:00:00, " + "End: 2013-08-21 23:59:00, " + "# Runs: 17, " + - "Mean: 14.687, " + - "Std Dev: 0.588, " + - "%CV: 4.004"); + "Mean: 28.854, " + + "Std Dev: 0.675, " + + "%CV: 2.339"); goToProjectHome(); DataRegionTable table = new DataRegionTable("TargetedMSRuns", getDriver()); From 246048490983db6c35b66f81cfb3e46e862ab5ef Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 29 Apr 2026 12:22:10 -0700 Subject: [PATCH 17/18] fix more test values for plots due to adjust for the new sort order - by precurson row id --- .../tests/targetedms/TargetedMSTrailingMeanAndCVTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java index b92e0348d..478f240df 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java @@ -160,9 +160,9 @@ public void testTrailingCVPlotType() Metric: Retention Time Peptide: - ATEEQLK ++, 409.7163 + VYVEELKPTPEGDLEILLQK ++, 1,157.1330 Value: - 6.831 + 22.645 Replicate: 3 runs average Acquired: From e75820f0b82a2b1cf547df6f1978f9e7d790f97f Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Thu, 30 Apr 2026 10:16:23 -0700 Subject: [PATCH 18/18] fix more test values for plots due to adjust for the new sort order - by precurson row id --- .../targetedms/TargetedMSTrailingMeanAndCVTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java index 478f240df..b0897a054 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java @@ -93,9 +93,9 @@ public void testTrailingMeanPlotType() Metric: Retention Time Peptide: - ATEEQLK ++, 409.7163 + VYVEELKPTPEGDLEILLQK ++, 1,157.1330 Value: - 15.652 + 35.156 Replicate: 3 runs average Acquired: @@ -117,9 +117,9 @@ public void testTrailingMeanPlotType() Metric: Retention Time Peptide: - ATEEQLK ++, 409.7163 + VYVEELKPTPEGDLEILLQK ++, 1,157.1330 Value: - 15.684 + 35.102 Replicate: 3 runs average Acquired: @@ -183,9 +183,9 @@ public void testTrailingCVPlotType() Metric: Retention Time Peptide: - ATEEQLK ++, 409.7163 + VYVEELKPTPEGDLEILLQK ++, 1,157.1330 Value: - 3.704 + 2.225 Replicate: 3 runs average Acquired: