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 f03f47b8c..176ecb8a7 100644 --- a/src/org/labkey/targetedms/model/QCPlotFragment.java +++ b/src/org/labkey/targetedms/model/QCPlotFragment.java @@ -21,6 +21,12 @@ public class QCPlotFragment private List guideSetStats; @Nullable private Color _seriesColor; + @Nullable + private Long precursorRowId; + @Nullable + private Long peptideGroupId; + @Nullable + private String peptideGroupLabel; public String getSeriesLabel() { @@ -77,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()); @@ -205,4 +216,37 @@ public Color getSeriesColor() { return _seriesColor; } + + @Nullable + public Long getPrecursorRowId() + { + return precursorRowId; + } + + 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 e133136ea..f2c42bbdb 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; @@ -539,7 +541,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()); @@ -559,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(); @@ -588,10 +595,21 @@ 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 = peptideGroupCache.computeIfAbsent(molecule.getPeptideGroupId(), id -> PeptideGroupManager.getPeptideGroup(c, id)); + if (peptideGroup != null) + { + entry.getValue().setPeptideGroupId(peptideGroup.getId()); + entry.getValue().setPeptideGroupLabel(peptideGroup.getLabel()); + } } } - 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; } 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()); diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSTrailingMeanAndCVTest.java index b92e0348d..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: @@ -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: @@ -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: diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css index 80aab9dfd..b4434fed4 100644 --- a/webapp/TargetedMS/css/qcTrendPlotReport.css +++ b/webapp/TargetedMS/css/qcTrendPlotReport.css @@ -102,4 +102,17 @@ .x4-panel-header-text-container-default { font-weight: normal; +} + +.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 c4b7c7dc3..fdfe805e2 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -8,7 +8,12 @@ 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: '|', + // 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() { @@ -412,9 +417,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); } @@ -602,10 +609,11 @@ 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] + color: groupColors[i % groupColors.length], + shape: LABKEY.targetedms.QCPlotHelperBase.SQUARE_LEGEND_SHAPE }); } } @@ -628,10 +636,11 @@ 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] + color: groupColors[(this.precursors.length + i) % groupColors.length], + shape: LABKEY.targetedms.QCPlotHelperBase.SQUARE_LEGEND_SHAPE }); } } @@ -822,6 +831,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/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 529153071..8eb407e76 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, runs: null, trailingRuns: null, minWidth: 1250, // Keep in sync with the width defined in qcTrendPlot.jsp @@ -147,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; } @@ -1612,27 +1624,404 @@ 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) { + 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'); + 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) { + 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); 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); - var legendItems = d3.selectAll('.legend .legend-item'); - var legendOpacityAcc = function(d) { - if (!d.name) return 1; - return d.name.indexOf(fragment + (hasYRightMetric ? '|' : '')) === 0 ? 1 : 0.1 - }; - legendItems.attr('fill-opacity', legendOpacityAcc).attr('stroke-opacity', legendOpacityAcc); + let legendItems = d3.selectAll('.legend .legend-item'); + legendItems.each(function(d) { + if (!d.name) return; + let frag = d.hoverText || d.name.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]; + 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); + 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, groupIdx, 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(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(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[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'); + }); + this.applyTreeGroupLabelHighlight(treeDiv, groupIdx); + } + }, + + // 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(); + this.havePlotOptionsChanged = true; + this.persistSelectedFormOptions(); + }, + + 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(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]] ? 'none' : null; + }); + + d3.selectAll('.legend .legend-item').each(function(d) { + 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); + }); + + this.updateTreeLegendState(); + }, + + 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(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]); + }); + }); + 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 legendTop = 65 + this.getMaxStackedAnnotations() * 12; + 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'; + treeDiv.style.cssText = [ + 'position: absolute', + 'right: 0', + 'top: ' + legendTop + 'px', + 'width: ' + legendMargin + 'px', + 'max-height: ' + Math.max(50, maxHeight) + 'px', + 'overflow-y: auto', + '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); + + d3.selectAll('[id^="combinedPlot"] .legend').style('display', 'none'); + }, + + makeColorCheckboxSVG: function(color, checked) { + if (checked) { + return '' + + '' + + '' + + ''; + } + return '' + + '' + + ''; + }, + + 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]; }); + let out = '
'; + out += ''; + out += '
'; + 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'; + out += '
'; + out += this.makeColorCheckboxSVG(color, !hidden[fragment]); + 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); + } + } + if (ionGroups.length > 0) { + html += '
Ions
'; + for (let i = 0; i < ionGroups.length; i++) { + html += renderGroup(ionGroups[i].group, ionGroups[i].idx); + } + } + + 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')); + }); + + el.addEventListener('mouseenter', function() { + let fragment = el.getAttribute('data-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(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[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'); + }); + }); + + el.addEventListener('mouseleave', function() { + me.applySeriesVisibility(); // re-applies display:none on hidden series + me.plotPointMouseOut(); // resets plot opacity and tree label opacity + }); + }); + + 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; + + 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(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]) !== -1) d3.select(this).attr('display', null); + }); + } + me.highlightGroupSeries(group.fragments, groupIdx, treeDiv); + }); + label.addEventListener('mouseleave', function() { + me.applySeriesVisibility(); + me.plotPointMouseOut(); + }); + } + + 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(); + me.havePlotOptionsChanged = true; + me.persistSelectedFormOptions(); + }); + }); + }, + + 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) { + 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) { + 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) { @@ -2651,6 +3040,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, @@ -2662,7 +3055,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