From 545f327a199f3a0e2564c0c0f47193496302f337 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Thu, 14 May 2026 17:30:28 -0700 Subject: [PATCH 1/9] Click/drag zooming for QC plots --- webapp/TargetedMS/css/qcTrendPlotReport.css | 21 +++ webapp/TargetedMS/js/QCPlotHelperBase.js | 138 ++++++++++++++++++++ webapp/TargetedMS/js/QCTrendPlotPanel.js | 27 +++- 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css index b4434fed4..3b9381cd7 100644 --- a/webapp/TargetedMS/css/qcTrendPlotReport.css +++ b/webapp/TargetedMS/css/qcTrendPlotReport.css @@ -115,4 +115,25 @@ .qc-combined-tree-legend .qc-tree-precursor:hover { background-color: #f0f0f0; +} + +.y-zoom-overlay { + cursor: ns-resize; +} + +.y-zoom-selection { + fill: rgba(20, 204, 201, 0.3); + stroke: rgba(20, 204, 201, 1); + stroke-width: 1px; +} + +.y-zoom-buttons g { + cursor: pointer; +} + +.qc-reset-zoom-link { + fill: #126495; + font-size: 11px; + cursor: pointer; + text-decoration: underline; } \ No newline at end of file diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index 85cacafa4..5a902309f 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -805,6 +805,11 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } Ext4.apply(trendLineProps, this.getPlotTypeProperties(combinePlotData, plotType, isCUSUMMean, metricProps)); + var yZoomDomainCombined = this.getYZoomDomain ? this.getYZoomDomain(id) : null; + if (yZoomDomainCombined) { + trendLineProps.yZoomDomain = yZoomDomainCombined; + } + // Suppress the mean line for multi-series plots trendLineProps.mean = undefined; @@ -860,6 +865,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { const plot = LABKEY.vis.TrendingLinePlot(plotConfig); plot.render(); + this.addYZoomInteraction(plot, id); this.attachCombinedLegendClickHandlers(); this.addAnnotationsToPlot(plot, combinePlotData); @@ -945,6 +951,11 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { Ext4.apply(trendLineProps, this.getPlotTypeProperties(precursorInfo, plotType, isCUSUMMean, metricProps)); + var yZoomDomain = this.getYZoomDomain ? this.getYZoomDomain(id) : null; + if (yZoomDomain) { + trendLineProps.yZoomDomain = yZoomDomain; + } + var plotLegendData = this.getAdditionalPlotLegend(plotType); if (Ext4.isArray(this.legendData)) { plotLegendData = plotLegendData.concat(this.legendData); @@ -1022,6 +1033,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { const plot = LABKEY.vis.TrendingLinePlot(plotConfig); plot.render(); + this.addYZoomInteraction(plot, id); this.addAnnotationsToPlot(plot, precursorInfo); this.addGuideSetTrainingRangeToPlot(plot, precursorInfo); @@ -1065,5 +1077,131 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { showInPlotLegends: function () { return true; + }, + + addYZoomInteraction: function(plot, plotId) { + var me = this; + var svg = this.getSvgElForPlot(plot); + var grid = plot.grid; + + if (!plot.scales.yLeft || !plot.scales.yLeft.scale || !plot.scales.yLeft.scale.invert) { + return; + } + + var yScale = plot.scales.yLeft.scale; + var overlayWidth = grid.leftEdge - 2; + var gridTop = grid.topEdge; + var gridBottom = grid.bottomEdge; + + var dragStartY = null; + var dragCurrentY = null; + var selectionRect = null; + var zoomButtonGroup = null; + + var clampY = function(y) { + return Math.max(gridTop, Math.min(gridBottom, y)); + }; + + var removeOverlays = function() { + if (selectionRect) { + selectionRect.remove(); + selectionRect = null; + } + if (zoomButtonGroup) { + zoomButtonGroup.remove(); + zoomButtonGroup = null; + } + }; + + var drag = d3.behavior.drag() + .on('dragstart', function() { + dragStartY = clampY(d3.mouse(svg.node())[1]); + dragCurrentY = dragStartY; + removeOverlays(); + }) + .on('drag', function() { + dragCurrentY = clampY(d3.mouse(svg.node())[1]); + + var y1 = Math.min(dragStartY, dragCurrentY); + var y2 = Math.max(dragStartY, dragCurrentY); + var h = y2 - y1; + + if (h < 1) { return; } + + if (selectionRect) { + selectionRect.attr('y', y1).attr('height', h); + } + else { + selectionRect = svg.append('rect') + .attr('class', 'y-zoom-selection') + .attr('x', 1) + .attr('y', y1) + .attr('width', overlayWidth - 2) + .attr('height', h) + .style('pointer-events', 'none'); + } + }) + .on('dragend', function() { + var y1 = Math.min(dragStartY, dragCurrentY); + var y2 = Math.max(dragStartY, dragCurrentY); + + if (y2 - y1 < 5) { + removeOverlays(); + return; + } + + // SVG y is inverted: smaller pixel y = larger domain value + var domainMax = yScale.invert(y1); + var domainMin = yScale.invert(y2); + + var yMid = y1 + (y2 - y1) / 2; + var xMid = overlayWidth / 2; + + zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); + + var makeBtn = function(text, xLeft, width, onClick) { + var btnG = zoomButtonGroup.append('g'); + btnG.append('rect') + .attr('x', xLeft).attr('y', yMid - 10).attr('rx', 5).attr('ry', 5) + .attr('width', width).attr('height', 20) + .style({'fill': '#ffffff', 'stroke': '#b4b4b4'}); + btnG.append('text') + .text(text) + .attr('x', xLeft + 5).attr('y', yMid + 4) + .style({'fill': '#126495', 'font-size': '10px', 'font-weight': 'bold', + 'text-transform': 'uppercase', 'pointer-events': 'none'}); + btnG.on('click', onClick); + return btnG; + }; + + makeBtn('Zoom', xMid - 57, 50, function() { + removeOverlays(); + me.applyYZoom(plotId, domainMin, domainMax); + }); + + makeBtn('Cancel', xMid + 3, 55, function() { + removeOverlays(); + }); + }); + + svg.append('rect') + .attr('class', 'y-zoom-overlay') + .attr('x', 0) + .attr('y', gridTop) + .attr('width', overlayWidth) + .attr('height', gridBottom - gridTop) + .style('fill', 'transparent') + .call(drag); + + if (this.getYZoomDomain && this.getYZoomDomain(plotId)) { + svg.append('text') + .attr('class', 'qc-reset-zoom-link') + .text('Reset Zoom') + .attr('x', grid.leftEdge + 5) + .attr('y', grid.topEdge - 5) + .on('click', function() { + me.resetYZoom(plotId); + }); + } } }); \ No newline at end of file diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 55ae8f4e2..5092f8958 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -82,6 +82,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { trailingRuns: null, minWidth: 1250, // Keep in sync with the width defined in qcTrendPlot.jsp width: '100%', + yZoomByPlot: {}, SHOW_ALL_IN_A_SINGLE_PLOT: 'Show all series in a single plot', LABEL_WIDTH: 115, @@ -1213,8 +1214,10 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { Ext4.get(this.plotDivId).mask("Loading..."); }, - displayTrendPlot: function() { - + displayTrendPlot: function(preserveZoom) { + if (!preserveZoom) { + this.yZoomByPlot = {}; + } this.setBrushingEnabled(false); this.updateSelectedAnnotations(); this.setLoadingMsg(); @@ -2223,6 +2226,26 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { } }, + getYZoomDomain: function(plotId) { + return this.yZoomByPlot && this.yZoomByPlot[plotId] ? this.yZoomByPlot[plotId] : null; + }, + + applyYZoom: function(plotId, yMin, yMax) { + if (!this.yZoomByPlot) { + this.yZoomByPlot = {}; + } + this.yZoomByPlot[plotId] = [yMin, yMax]; + this.displayTrendPlot(true /* preserveZoom */); + // TODO: add server-side metric tracking action (targetedms/trackQCPlotAction.api) + }, + + resetYZoom: function(plotId) { + if (this.yZoomByPlot) { + delete this.yZoomByPlot[plotId]; + } + this.displayTrendPlot(true /* preserveZoom */); + }, + getSvgElForPlot : function(plot) { return d3.select('#' + plot.renderTo + ' svg'); }, From 246462c59eea12a127b2ca740de18b7691c27652 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 20 May 2026 16:12:09 -0700 Subject: [PATCH 2/9] add border rectangle when zooming --- webapp/TargetedMS/js/QCPlotHelperBase.js | 34 ++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index 5a902309f..e11f8af6b 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -1155,7 +1155,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { var domainMin = yScale.invert(y2); var yMid = y1 + (y2 - y1) / 2; - var xMid = overlayWidth / 2; + var btnLeft = grid.leftEdge + 5; zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); @@ -1174,12 +1174,12 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { return btnG; }; - makeBtn('Zoom', xMid - 57, 50, function() { + makeBtn('Zoom', btnLeft, 50, function() { removeOverlays(); me.applyYZoom(plotId, domainMin, domainMax); }); - makeBtn('Cancel', xMid + 3, 55, function() { + makeBtn('Cancel', btnLeft + 60, 55, function() { removeOverlays(); }); }); @@ -1194,11 +1194,35 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .call(drag); if (this.getYZoomDomain && this.getYZoomDomain(plotId)) { + var gridLeft = grid.leftEdge; + var gridRight = grid.rightEdge; + var gridWidth = gridRight - gridLeft; + var gridHeight = gridBottom - gridTop; + var clipId = (plot.renderTo || plotId) + '-yzoom-clip'; + + var svgDefs = svg.select('defs'); + if (svgDefs.empty()) { + svgDefs = svg.insert('defs', ':first-child'); + } + svgDefs.append('clipPath') + .attr('id', clipId) + .append('rect') + .attr('x', gridLeft).attr('y', gridTop) + .attr('width', gridWidth).attr('height', gridHeight); + + svg.selectAll('g.layer').attr('clip-path', 'url(#' + clipId + ')'); + + svg.append('rect') + .attr('class', 'y-zoom-border') + .attr('x', gridLeft).attr('y', gridTop) + .attr('width', gridWidth).attr('height', gridHeight) + .style({'fill': 'none', 'stroke': '#888', 'stroke-width': '1px', 'pointer-events': 'none'}); + svg.append('text') .attr('class', 'qc-reset-zoom-link') .text('Reset Zoom') - .attr('x', grid.leftEdge + 5) - .attr('y', grid.topEdge - 5) + .attr('x', gridLeft + 5) + .attr('y', gridTop - 5) .on('click', function() { me.resetYZoom(plotId); }); From 4c6f034232037cf78bb9424fbfb7d8463fed5fdc Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 20 May 2026 16:21:49 -0700 Subject: [PATCH 3/9] change the mouse pointer and color the entire zoom area when selecting the range on y-axis --- webapp/TargetedMS/css/qcTrendPlotReport.css | 2 +- webapp/TargetedMS/js/QCPlotHelperBase.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css index 3b9381cd7..da996694b 100644 --- a/webapp/TargetedMS/css/qcTrendPlotReport.css +++ b/webapp/TargetedMS/css/qcTrendPlotReport.css @@ -118,7 +118,7 @@ } .y-zoom-overlay { - cursor: ns-resize; + cursor: zoom-in; } .y-zoom-selection { diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index e11f8af6b..72ba4c2f7 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -1092,6 +1092,8 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { var overlayWidth = grid.leftEdge - 2; var gridTop = grid.topEdge; var gridBottom = grid.bottomEdge; + var gridLeft = grid.leftEdge; + var gridRight = grid.rightEdge; var dragStartY = null; var dragCurrentY = null; @@ -1134,9 +1136,9 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { else { selectionRect = svg.append('rect') .attr('class', 'y-zoom-selection') - .attr('x', 1) + .attr('x', gridLeft) .attr('y', y1) - .attr('width', overlayWidth - 2) + .attr('width', gridRight - gridLeft) .attr('height', h) .style('pointer-events', 'none'); } @@ -1194,8 +1196,6 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .call(drag); if (this.getYZoomDomain && this.getYZoomDomain(plotId)) { - var gridLeft = grid.leftEdge; - var gridRight = grid.rightEdge; var gridWidth = gridRight - gridLeft; var gridHeight = gridBottom - gridTop; var clipId = (plot.renderTo || plotId) + '-yzoom-clip'; @@ -1222,7 +1222,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .attr('class', 'qc-reset-zoom-link') .text('Reset Zoom') .attr('x', gridLeft + 5) - .attr('y', gridTop - 5) + .attr('y', gridTop - 18) .on('click', function() { me.resetYZoom(plotId); }); From ceb40f18c3896c4a05f55d6a0751c08da1048b35 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 20 May 2026 16:25:44 -0700 Subject: [PATCH 4/9] use let and const --- webapp/TargetedMS/js/QCPlotHelperBase.js | 66 ++++++++++++------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index 72ba4c2f7..e71bf4779 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -805,7 +805,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } Ext4.apply(trendLineProps, this.getPlotTypeProperties(combinePlotData, plotType, isCUSUMMean, metricProps)); - var yZoomDomainCombined = this.getYZoomDomain ? this.getYZoomDomain(id) : null; + let yZoomDomainCombined = this.getYZoomDomain ? this.getYZoomDomain(id) : null; if (yZoomDomainCombined) { trendLineProps.yZoomDomain = yZoomDomainCombined; } @@ -951,7 +951,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { Ext4.apply(trendLineProps, this.getPlotTypeProperties(precursorInfo, plotType, isCUSUMMean, metricProps)); - var yZoomDomain = this.getYZoomDomain ? this.getYZoomDomain(id) : null; + let yZoomDomain = this.getYZoomDomain ? this.getYZoomDomain(id) : null; if (yZoomDomain) { trendLineProps.yZoomDomain = yZoomDomain; } @@ -1080,31 +1080,31 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { }, addYZoomInteraction: function(plot, plotId) { - var me = this; - var svg = this.getSvgElForPlot(plot); - var grid = plot.grid; + let me = this; + let svg = this.getSvgElForPlot(plot); + let grid = plot.grid; if (!plot.scales.yLeft || !plot.scales.yLeft.scale || !plot.scales.yLeft.scale.invert) { return; } - var yScale = plot.scales.yLeft.scale; - var overlayWidth = grid.leftEdge - 2; - var gridTop = grid.topEdge; - var gridBottom = grid.bottomEdge; - var gridLeft = grid.leftEdge; - var gridRight = grid.rightEdge; + let yScale = plot.scales.yLeft.scale; + let overlayWidth = grid.leftEdge - 2; + let gridTop = grid.topEdge; + let gridBottom = grid.bottomEdge; + let gridLeft = grid.leftEdge; + let gridRight = grid.rightEdge; - var dragStartY = null; - var dragCurrentY = null; - var selectionRect = null; - var zoomButtonGroup = null; + let dragStartY = null; + let dragCurrentY = null; + let selectionRect = null; + let zoomButtonGroup = null; - var clampY = function(y) { + let clampY = function(y) { return Math.max(gridTop, Math.min(gridBottom, y)); }; - var removeOverlays = function() { + let removeOverlays = function() { if (selectionRect) { selectionRect.remove(); selectionRect = null; @@ -1115,7 +1115,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } }; - var drag = d3.behavior.drag() + let drag = d3.behavior.drag() .on('dragstart', function() { dragStartY = clampY(d3.mouse(svg.node())[1]); dragCurrentY = dragStartY; @@ -1124,9 +1124,9 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .on('drag', function() { dragCurrentY = clampY(d3.mouse(svg.node())[1]); - var y1 = Math.min(dragStartY, dragCurrentY); - var y2 = Math.max(dragStartY, dragCurrentY); - var h = y2 - y1; + let y1 = Math.min(dragStartY, dragCurrentY); + let y2 = Math.max(dragStartY, dragCurrentY); + let h = y2 - y1; if (h < 1) { return; } @@ -1144,8 +1144,8 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } }) .on('dragend', function() { - var y1 = Math.min(dragStartY, dragCurrentY); - var y2 = Math.max(dragStartY, dragCurrentY); + let y1 = Math.min(dragStartY, dragCurrentY); + let y2 = Math.max(dragStartY, dragCurrentY); if (y2 - y1 < 5) { removeOverlays(); @@ -1153,16 +1153,16 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } // SVG y is inverted: smaller pixel y = larger domain value - var domainMax = yScale.invert(y1); - var domainMin = yScale.invert(y2); + let domainMax = yScale.invert(y1); + let domainMin = yScale.invert(y2); - var yMid = y1 + (y2 - y1) / 2; - var btnLeft = grid.leftEdge + 5; + let yMid = y1 + (y2 - y1) / 2; + let btnLeft = grid.leftEdge + 5; zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); - var makeBtn = function(text, xLeft, width, onClick) { - var btnG = zoomButtonGroup.append('g'); + let makeBtn = function(text, xLeft, width, onClick) { + let btnG = zoomButtonGroup.append('g'); btnG.append('rect') .attr('x', xLeft).attr('y', yMid - 10).attr('rx', 5).attr('ry', 5) .attr('width', width).attr('height', 20) @@ -1196,11 +1196,11 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .call(drag); if (this.getYZoomDomain && this.getYZoomDomain(plotId)) { - var gridWidth = gridRight - gridLeft; - var gridHeight = gridBottom - gridTop; - var clipId = (plot.renderTo || plotId) + '-yzoom-clip'; + let gridWidth = gridRight - gridLeft; + let gridHeight = gridBottom - gridTop; + let clipId = (plot.renderTo || plotId) + '-yzoom-clip'; - var svgDefs = svg.select('defs'); + let svgDefs = svg.select('defs'); if (svgDefs.empty()) { svgDefs = svg.insert('defs', ':first-child'); } From c82ebb754fd4f552fd23df24abf771c515533455 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 20 May 2026 17:00:28 -0700 Subject: [PATCH 5/9] add test and testing support for y-axis zoom --- .../components/targetedms/QCPlotsWebPart.java | 40 ++++++++++++++ .../tests/targetedms/TargetedMSQCTest.java | 55 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java index 1a2792f5c..fcc5c666c 100644 --- a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java +++ b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java @@ -904,6 +904,42 @@ public String toString() } } + public void performYAxisZoom(QCPlot qcPlot) + { + WebElement plotEl = qcPlot.getPlot(); + WebElement overlay = elementCache().yZoomOverlay.findElement(plotEl); + getWrapper().scrollIntoView(overlay); + + int dragOffset = 50; + new Actions(getWrapper().getDriver()) + .moveToElement(overlay, 0, -dragOffset) + .clickAndHold() + .moveToElement(overlay, 0, dragOffset) + .release() + .perform(); + + WebDriverWrapper.waitFor(() -> !elementCache().yZoomButtons.findElements(plotEl).isEmpty(), + "Zoom buttons did not appear after y-axis drag", WAIT_FOR_JAVASCRIPT); + + WebElement buttonsGroup = elementCache().yZoomButtons.findElement(plotEl); + Locator.css("g").findElement(buttonsGroup).click(); + } + + public boolean isZoomActive(QCPlot qcPlot) + { + return !elementCache().yZoomBorder.findElements(qcPlot.getPlot()).isEmpty(); + } + + public boolean isResetZoomVisible(QCPlot qcPlot) + { + return !elementCache().yZoomResetLink.findElements(qcPlot.getPlot()).isEmpty(); + } + + public void clickResetZoom(QCPlot qcPlot) + { + elementCache().yZoomResetLink.findElement(qcPlot.getPlot()).click(); + } + public class Elements extends BodyWebPart.ElementCache { WebElement startDate = Locator.css("#start-date-field input").findWhenNeeded(this); @@ -939,6 +975,10 @@ public class Elements extends BodyWebPart.ElementCache WebElement plotPanel = Locator.css("div.tiledPlotPanel").findWhenNeeded(this); WebElement paginationPanel = Locator.css("div.plotPaginationHeaderPanel").findWhenNeeded(this); Locator extFormDisplay = Locator.css("div.x4-form-display-field"); + Locator.CssLocator yZoomOverlay = Locator.css("svg rect.y-zoom-overlay"); + Locator.CssLocator yZoomButtons = Locator.css("svg g.y-zoom-buttons"); + Locator.CssLocator yZoomBorder = Locator.css("svg rect.y-zoom-border"); + Locator.CssLocator yZoomResetLink = Locator.css("svg text.qc-reset-zoom-link"); Locator.CssLocator guideSetTrainingRect = Locator.css("svg rect.training"); Locator.CssLocator experimentRangeRect = Locator.css("svg rect.expRange"); Locator.CssLocator guideSetSvgButton = Locator.css("svg g.guideset-svg-button text"); diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java index b51926618..5982cef4f 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java @@ -1148,6 +1148,61 @@ private void verifyRow(DataRegionTable drt, int row, String sampleName, String s assertEquals(skylineDocName, drt.getDataAsText(row, "File")); } + @Test + public void testQCPlotYAxisZoom() + { + PanoramaDashboard qcDashboard = new PanoramaDashboard(this); + QCPlotsWebPart qcPlotsWebPart = qcDashboard.getQcPlotsWebPart(); + qcPlotsWebPart.filterQCPlotsToInitialData(PRECURSORS.length, true); + + List plots = qcPlotsWebPart.getPlots(); + assertTrue("Expected at least 2 plots for y-axis zoom test", plots.size() >= 2); + + // 1. Verify zooming is possible: drag on y-axis, zoom buttons appear, zoom applies + log("Verifying y-axis zoom can be applied"); + qcPlotsWebPart.performYAxisZoom(plots.get(0)); + waitForElement(Locator.css("svg rect.y-zoom-border"), WAIT_FOR_JAVASCRIPT); + + plots = qcPlotsWebPart.getPlots(); + QCPlot firstPlot = plots.get(0); + QCPlot secondPlot = plots.get(1); + + assertTrue("Zoom border should appear on first plot after zoom", qcPlotsWebPart.isZoomActive(firstPlot)); + assertTrue("Reset Zoom link should appear on first plot after zoom", qcPlotsWebPart.isResetZoomVisible(firstPlot)); + + // 2. Verify zoom is per-plot: second plot is unaffected + log("Verifying zoom is independent per plot"); + assertFalse("Second plot should not be zoomed", qcPlotsWebPart.isZoomActive(secondPlot)); + assertFalse("Reset Zoom link should not appear on second plot", qcPlotsWebPart.isResetZoomVisible(secondPlot)); + + // 3. Verify reset works for the zoomed plot only + log("Verifying Reset Zoom removes zoom on the target plot"); + qcPlotsWebPart.clickResetZoom(firstPlot); + waitForElementToDisappear(Locator.css("svg rect.y-zoom-border"), WAIT_FOR_JAVASCRIPT); + + plots = qcPlotsWebPart.getPlots(); + firstPlot = plots.get(0); + + assertFalse("Zoom border should be gone after reset", qcPlotsWebPart.isZoomActive(firstPlot)); + assertFalse("Reset Zoom link should be gone after reset", qcPlotsWebPart.isResetZoomVisible(firstPlot)); + + // 4. Verify zoom is not persisted after page reload + log("Verifying zoom state is cleared on page reload"); + qcPlotsWebPart.performYAxisZoom(firstPlot); + waitForElement(Locator.css("svg rect.y-zoom-border"), WAIT_FOR_JAVASCRIPT); + + refresh(); + qcDashboard = new PanoramaDashboard(this); + qcPlotsWebPart = qcDashboard.getQcPlotsWebPart(); + qcPlotsWebPart.filterQCPlotsToInitialData(PRECURSORS.length, true); + + plots = qcPlotsWebPart.getPlots(); + firstPlot = plots.get(0); + + assertFalse("Zoom should not persist after page reload", qcPlotsWebPart.isZoomActive(firstPlot)); + assertFalse("Reset Zoom link should not appear after page reload", qcPlotsWebPart.isResetZoomVisible(firstPlot)); + } + private void createAndInsertAnnotations() { clickTab("Annotations"); From dbf08177f0878daab91bf18ffc1c56078cc685b7 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 26 May 2026 07:10:26 -0700 Subject: [PATCH 6/9] start and end bounds with clicks --- webapp/TargetedMS/css/qcTrendPlotReport.css | 6 + webapp/TargetedMS/js/QCPlotHelperBase.js | 164 ++++++++++++++------ 2 files changed, 122 insertions(+), 48 deletions(-) diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css index da996694b..e63214c7c 100644 --- a/webapp/TargetedMS/css/qcTrendPlotReport.css +++ b/webapp/TargetedMS/css/qcTrendPlotReport.css @@ -121,6 +121,12 @@ cursor: zoom-in; } +.y-zoom-pending-line { + stroke: rgba(20, 204, 201, 1); + stroke-width: 2px; + stroke-dasharray: 6, 3; +} + .y-zoom-selection { fill: rgba(20, 204, 201, 0.3); stroke: rgba(20, 204, 201, 1); diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index e71bf4779..42056d292 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -1099,31 +1099,109 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { let dragCurrentY = null; let selectionRect = null; let zoomButtonGroup = null; + let pendingLine = null; + let pendingStartY = null; let clampY = function(y) { return Math.max(gridTop, Math.min(gridBottom, y)); }; let removeOverlays = function() { - if (selectionRect) { - selectionRect.remove(); - selectionRect = null; - } - if (zoomButtonGroup) { - zoomButtonGroup.remove(); - zoomButtonGroup = null; - } + if (selectionRect) { selectionRect.remove(); selectionRect = null; } + if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } + if (pendingLine) { pendingLine.remove(); pendingLine = null; } + }; + + let showZoomButtons = function(y1, y2) { + let domainMax = yScale.invert(y1); + let domainMin = yScale.invert(y2); + let yMid = y1 + (y2 - y1) / 2; + let btnLeft = gridLeft + 5; + + zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); + + let makeBtn = function(text, xLeft, width, onClick) { + let btnG = zoomButtonGroup.append('g'); + btnG.append('rect') + .attr('x', xLeft).attr('y', yMid - 10).attr('rx', 5).attr('ry', 5) + .attr('width', width).attr('height', 20) + .style({'fill': '#ffffff', 'stroke': '#b4b4b4'}); + btnG.append('text') + .text(text) + .attr('x', xLeft + 5).attr('y', yMid + 4) + .style({'fill': '#126495', 'font-size': '10px', 'font-weight': 'bold', + 'text-transform': 'uppercase', 'pointer-events': 'none'}); + btnG.on('click', onClick); + return btnG; + }; + + makeBtn('Zoom', btnLeft, 50, function() { + removeOverlays(); + me.applyYZoom(plotId, domainMin, domainMax); + }); + + makeBtn('Cancel', btnLeft + 60, 55, function() { + removeOverlays(); + }); + }; + + let cancelPendingClick = function() { + pendingStartY = null; + svg.on('mousemove.yzoom', null); + d3.select(document).on('keydown.yzoom', null); + removeOverlays(); + }; + + let startClickModeTracking = function(startY) { + pendingStartY = startY; + + pendingLine = svg.append('line') + .attr('class', 'y-zoom-pending-line') + .attr('x1', gridLeft).attr('y1', startY) + .attr('x2', gridRight).attr('y2', startY) + .style('pointer-events', 'none'); + + svg.on('mousemove.yzoom', function() { + let currentY = clampY(d3.mouse(svg.node())[1]); + let y1 = Math.min(pendingStartY, currentY); + let y2 = Math.max(pendingStartY, currentY); + let h = y2 - y1; + + if (selectionRect) { + selectionRect.attr('x', gridLeft).attr('y', y1) + .attr('width', gridRight - gridLeft).attr('height', Math.max(1, h)); + } else { + selectionRect = svg.append('rect') + .attr('class', 'y-zoom-selection') + .attr('x', gridLeft).attr('y', y1) + .attr('width', gridRight - gridLeft) + .attr('height', Math.max(1, h)) + .style('pointer-events', 'none'); + } + }); + + d3.select(document).on('keydown.yzoom', function() { + if (d3.event.key === 'Escape' || d3.event.keyCode === 27) { + cancelPendingClick(); + } + }); }; let drag = d3.behavior.drag() .on('dragstart', function() { dragStartY = clampY(d3.mouse(svg.node())[1]); dragCurrentY = dragStartY; - removeOverlays(); + // Only dismiss zoom buttons; leave pending-click visuals intact + if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } }) .on('drag', function() { dragCurrentY = clampY(d3.mouse(svg.node())[1]); + // Real drag while in pending-click state + if (pendingStartY !== null && Math.abs(dragCurrentY - dragStartY) >= 5) { + cancelPendingClick(); + } + let y1 = Math.min(dragStartY, dragCurrentY); let y2 = Math.max(dragStartY, dragCurrentY); let h = y2 - y1; @@ -1131,13 +1209,12 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { if (h < 1) { return; } if (selectionRect) { - selectionRect.attr('y', y1).attr('height', h); - } - else { + selectionRect.attr('x', gridLeft).attr('y', y1) + .attr('width', gridRight - gridLeft).attr('height', h); + } else { selectionRect = svg.append('rect') .attr('class', 'y-zoom-selection') - .attr('x', gridLeft) - .attr('y', y1) + .attr('x', gridLeft).attr('y', y1) .attr('width', gridRight - gridLeft) .attr('height', h) .style('pointer-events', 'none'); @@ -1148,45 +1225,16 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { let y2 = Math.max(dragStartY, dragCurrentY); if (y2 - y1 < 5) { - removeOverlays(); + // Pure click — the click listener handles this return; } - // SVG y is inverted: smaller pixel y = larger domain value - let domainMax = yScale.invert(y1); - let domainMin = yScale.invert(y2); - - let yMid = y1 + (y2 - y1) / 2; - let btnLeft = grid.leftEdge + 5; - - zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); - - let makeBtn = function(text, xLeft, width, onClick) { - let btnG = zoomButtonGroup.append('g'); - btnG.append('rect') - .attr('x', xLeft).attr('y', yMid - 10).attr('rx', 5).attr('ry', 5) - .attr('width', width).attr('height', 20) - .style({'fill': '#ffffff', 'stroke': '#b4b4b4'}); - btnG.append('text') - .text(text) - .attr('x', xLeft + 5).attr('y', yMid + 4) - .style({'fill': '#126495', 'font-size': '10px', 'font-weight': 'bold', - 'text-transform': 'uppercase', 'pointer-events': 'none'}); - btnG.on('click', onClick); - return btnG; - }; - - makeBtn('Zoom', btnLeft, 50, function() { - removeOverlays(); - me.applyYZoom(plotId, domainMin, domainMax); - }); - - makeBtn('Cancel', btnLeft + 60, 55, function() { - removeOverlays(); - }); + // Real drag: cancel any pending-click state and show buttons + if (pendingStartY !== null) { cancelPendingClick(); } + showZoomButtons(y1, y2); }); - svg.append('rect') + let overlay = svg.append('rect') .attr('class', 'y-zoom-overlay') .attr('x', 0) .attr('y', gridTop) @@ -1195,6 +1243,26 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .style('fill', 'transparent') .call(drag); + overlay.on('click', function() { + let clickY = clampY(d3.mouse(svg.node())[1]); + + if (pendingStartY === null) { + // First click - set first bound and start live preview + removeOverlays(); + startClickModeTracking(clickY); + } else { + // Second click: finalize the range + let firstY = pendingStartY; + cancelPendingClick(); + + let finalY1 = Math.min(firstY, clickY); + let finalY2 = Math.max(firstY, clickY); + if (finalY2 - finalY1 < 5) { return; } + + showZoomButtons(finalY1, finalY2); + } + }); + if (this.getYZoomDomain && this.getYZoomDomain(plotId)) { let gridWidth = gridRight - gridLeft; let gridHeight = gridBottom - gridTop; From 5d63f3075d03bfefdc41bc03826e96b38c95532b Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 26 May 2026 09:44:59 -0700 Subject: [PATCH 7/9] center zoom and cancel button text --- webapp/TargetedMS/js/QCPlotHelperBase.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index 42056d292..d5453036d 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -1128,9 +1128,9 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .style({'fill': '#ffffff', 'stroke': '#b4b4b4'}); btnG.append('text') .text(text) - .attr('x', xLeft + 5).attr('y', yMid + 4) + .attr('x', xLeft + width / 2).attr('y', yMid + 4) .style({'fill': '#126495', 'font-size': '10px', 'font-weight': 'bold', - 'text-transform': 'uppercase', 'pointer-events': 'none'}); + 'text-anchor': 'middle', 'text-transform': 'uppercase', 'pointer-events': 'none'}); btnG.on('click', onClick); return btnG; }; From b708b694bb1ac25b6acdf7207e1ccd915ab8759b Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 26 May 2026 13:27:41 -0700 Subject: [PATCH 8/9] support right y axis zoom --- webapp/TargetedMS/js/QCPlotHelperBase.js | 323 ++++++++++++----------- webapp/TargetedMS/js/QCTrendPlotPanel.js | 26 +- 2 files changed, 189 insertions(+), 160 deletions(-) diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index d5453036d..220987b7a 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -807,7 +807,8 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { let yZoomDomainCombined = this.getYZoomDomain ? this.getYZoomDomain(id) : null; if (yZoomDomainCombined) { - trendLineProps.yZoomDomain = yZoomDomainCombined; + if (yZoomDomainCombined.left) trendLineProps.yZoomDomain = yZoomDomainCombined.left; + if (yZoomDomainCombined.right) trendLineProps.yZoomDomainRight = yZoomDomainCombined.right; } // Suppress the mean line for multi-series plots @@ -953,7 +954,8 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { let yZoomDomain = this.getYZoomDomain ? this.getYZoomDomain(id) : null; if (yZoomDomain) { - trendLineProps.yZoomDomain = yZoomDomain; + if (yZoomDomain.left) trendLineProps.yZoomDomain = yZoomDomain.left; + if (yZoomDomain.right) trendLineProps.yZoomDomainRight = yZoomDomain.right; } var plotLegendData = this.getAdditionalPlotLegend(plotType); @@ -1088,182 +1090,187 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { return; } - let yScale = plot.scales.yLeft.scale; - let overlayWidth = grid.leftEdge - 2; let gridTop = grid.topEdge; let gridBottom = grid.bottomEdge; let gridLeft = grid.leftEdge; let gridRight = grid.rightEdge; - let dragStartY = null; - let dragCurrentY = null; - let selectionRect = null; - let zoomButtonGroup = null; - let pendingLine = null; - let pendingStartY = null; - let clampY = function(y) { return Math.max(gridTop, Math.min(gridBottom, y)); }; - let removeOverlays = function() { - if (selectionRect) { selectionRect.remove(); selectionRect = null; } - if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } - if (pendingLine) { pendingLine.remove(); pendingLine = null; } - }; + // Creates an independent drag/click overlay for one y-axis (left or right). + // overlayX/overlayW define where the invisible hit area sits. + // btnAnchorX is the left edge of the Zoom button. + let setupAxisOverlay = function(axis, yScale, overlayX, overlayW, btnAnchorX) { + let dragStartY = null, dragCurrentY = null; + let selectionRect = null, zoomButtonGroup = null, pendingLine = null, pendingStartY = null; + let moveNs = 'mousemove.yzoom-' + axis; + let keyNs = 'keydown.yzoom-' + axis; + + let removeOverlays = function() { + if (selectionRect) { selectionRect.remove(); selectionRect = null; } + if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } + if (pendingLine) { pendingLine.remove(); pendingLine = null; } + }; - let showZoomButtons = function(y1, y2) { - let domainMax = yScale.invert(y1); - let domainMin = yScale.invert(y2); - let yMid = y1 + (y2 - y1) / 2; - let btnLeft = gridLeft + 5; - - zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); - - let makeBtn = function(text, xLeft, width, onClick) { - let btnG = zoomButtonGroup.append('g'); - btnG.append('rect') - .attr('x', xLeft).attr('y', yMid - 10).attr('rx', 5).attr('ry', 5) - .attr('width', width).attr('height', 20) - .style({'fill': '#ffffff', 'stroke': '#b4b4b4'}); - btnG.append('text') - .text(text) - .attr('x', xLeft + width / 2).attr('y', yMid + 4) - .style({'fill': '#126495', 'font-size': '10px', 'font-weight': 'bold', - 'text-anchor': 'middle', 'text-transform': 'uppercase', 'pointer-events': 'none'}); - btnG.on('click', onClick); - return btnG; + let showZoomButtons = function(y1, y2) { + let domainMax = yScale.invert(y1); + let domainMin = yScale.invert(y2); + let yMid = y1 + (y2 - y1) / 2; + + zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); + + let makeBtn = function(text, xLeft, width, onClick) { + let btnG = zoomButtonGroup.append('g'); + btnG.append('rect') + .attr('x', xLeft).attr('y', yMid - 10).attr('rx', 5).attr('ry', 5) + .attr('width', width).attr('height', 20) + .style({'fill': '#ffffff', 'stroke': '#b4b4b4'}); + btnG.append('text') + .text(text) + .attr('x', xLeft + width / 2).attr('y', yMid + 4) + .style({'fill': '#126495', 'font-size': '10px', 'font-weight': 'bold', + 'text-anchor': 'middle', 'text-transform': 'uppercase', 'pointer-events': 'none'}); + btnG.on('click', onClick); + return btnG; + }; + + makeBtn('Zoom', btnAnchorX, 50, function() { + removeOverlays(); + me.applyYZoom(plotId, domainMin, domainMax, axis); + }); + + makeBtn('Cancel', btnAnchorX + 60, 55, function() { + removeOverlays(); + }); }; - makeBtn('Zoom', btnLeft, 50, function() { + let cancelPendingClick = function() { + pendingStartY = null; + svg.on(moveNs, null); + d3.select(document).on(keyNs, null); removeOverlays(); - me.applyYZoom(plotId, domainMin, domainMax); - }); + }; - makeBtn('Cancel', btnLeft + 60, 55, function() { - removeOverlays(); - }); - }; + let startClickModeTracking = function(startY) { + pendingStartY = startY; - let cancelPendingClick = function() { - pendingStartY = null; - svg.on('mousemove.yzoom', null); - d3.select(document).on('keydown.yzoom', null); - removeOverlays(); - }; + pendingLine = svg.append('line') + .attr('class', 'y-zoom-pending-line') + .attr('x1', gridLeft).attr('y1', startY) + .attr('x2', gridRight).attr('y2', startY) + .style('pointer-events', 'none'); - let startClickModeTracking = function(startY) { - pendingStartY = startY; + svg.on(moveNs, function() { + let currentY = clampY(d3.mouse(svg.node())[1]); + let y1 = Math.min(pendingStartY, currentY); + let y2 = Math.max(pendingStartY, currentY); + let h = y2 - y1; - pendingLine = svg.append('line') - .attr('class', 'y-zoom-pending-line') - .attr('x1', gridLeft).attr('y1', startY) - .attr('x2', gridRight).attr('y2', startY) - .style('pointer-events', 'none'); + if (selectionRect) { + selectionRect.attr('x', gridLeft).attr('y', y1) + .attr('width', gridRight - gridLeft).attr('height', Math.max(1, h)); + } else { + selectionRect = svg.append('rect') + .attr('class', 'y-zoom-selection') + .attr('x', gridLeft).attr('y', y1) + .attr('width', gridRight - gridLeft) + .attr('height', Math.max(1, h)) + .style('pointer-events', 'none'); + } + }); - svg.on('mousemove.yzoom', function() { - let currentY = clampY(d3.mouse(svg.node())[1]); - let y1 = Math.min(pendingStartY, currentY); - let y2 = Math.max(pendingStartY, currentY); - let h = y2 - y1; + d3.select(document).on(keyNs, function() { + if (d3.event.key === 'Escape' || d3.event.keyCode === 27) { + cancelPendingClick(); + } + }); + }; - if (selectionRect) { - selectionRect.attr('x', gridLeft).attr('y', y1) - .attr('width', gridRight - gridLeft).attr('height', Math.max(1, h)); - } else { - selectionRect = svg.append('rect') - .attr('class', 'y-zoom-selection') - .attr('x', gridLeft).attr('y', y1) - .attr('width', gridRight - gridLeft) - .attr('height', Math.max(1, h)) - .style('pointer-events', 'none'); - } - }); + let drag = d3.behavior.drag() + .on('dragstart', function() { + dragStartY = clampY(d3.mouse(svg.node())[1]); + dragCurrentY = dragStartY; + if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } + }) + .on('drag', function() { + dragCurrentY = clampY(d3.mouse(svg.node())[1]); - d3.select(document).on('keydown.yzoom', function() { - if (d3.event.key === 'Escape' || d3.event.keyCode === 27) { - cancelPendingClick(); - } - }); - }; + if (pendingStartY !== null && Math.abs(dragCurrentY - dragStartY) >= 5) { + cancelPendingClick(); + } - let drag = d3.behavior.drag() - .on('dragstart', function() { - dragStartY = clampY(d3.mouse(svg.node())[1]); - dragCurrentY = dragStartY; - // Only dismiss zoom buttons; leave pending-click visuals intact - if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } - }) - .on('drag', function() { - dragCurrentY = clampY(d3.mouse(svg.node())[1]); + let y1 = Math.min(dragStartY, dragCurrentY); + let y2 = Math.max(dragStartY, dragCurrentY); + let h = y2 - y1; - // Real drag while in pending-click state - if (pendingStartY !== null && Math.abs(dragCurrentY - dragStartY) >= 5) { - cancelPendingClick(); - } + if (h < 1) { return; } - let y1 = Math.min(dragStartY, dragCurrentY); - let y2 = Math.max(dragStartY, dragCurrentY); - let h = y2 - y1; + if (selectionRect) { + selectionRect.attr('x', gridLeft).attr('y', y1) + .attr('width', gridRight - gridLeft).attr('height', h); + } else { + selectionRect = svg.append('rect') + .attr('class', 'y-zoom-selection') + .attr('x', gridLeft).attr('y', y1) + .attr('width', gridRight - gridLeft) + .attr('height', h) + .style('pointer-events', 'none'); + } + }) + .on('dragend', function() { + let y1 = Math.min(dragStartY, dragCurrentY); + let y2 = Math.max(dragStartY, dragCurrentY); - if (h < 1) { return; } + if (y2 - y1 < 5) { return; } - if (selectionRect) { - selectionRect.attr('x', gridLeft).attr('y', y1) - .attr('width', gridRight - gridLeft).attr('height', h); + if (pendingStartY !== null) { cancelPendingClick(); } + showZoomButtons(y1, y2); + }); + + let overlayEl = svg.append('rect') + .attr('class', 'y-zoom-overlay') + .attr('x', overlayX) + .attr('y', gridTop) + .attr('width', overlayW) + .attr('height', gridBottom - gridTop) + .style('fill', 'transparent') + .call(drag); + + overlayEl.on('click', function() { + let clickY = clampY(d3.mouse(svg.node())[1]); + + if (pendingStartY === null) { + removeOverlays(); + startClickModeTracking(clickY); } else { - selectionRect = svg.append('rect') - .attr('class', 'y-zoom-selection') - .attr('x', gridLeft).attr('y', y1) - .attr('width', gridRight - gridLeft) - .attr('height', h) - .style('pointer-events', 'none'); - } - }) - .on('dragend', function() { - let y1 = Math.min(dragStartY, dragCurrentY); - let y2 = Math.max(dragStartY, dragCurrentY); + let firstY = pendingStartY; + cancelPendingClick(); - if (y2 - y1 < 5) { - // Pure click — the click listener handles this - return; - } + let finalY1 = Math.min(firstY, clickY); + let finalY2 = Math.max(firstY, clickY); + if (finalY2 - finalY1 < 5) { return; } - // Real drag: cancel any pending-click state and show buttons - if (pendingStartY !== null) { cancelPendingClick(); } - showZoomButtons(y1, y2); + showZoomButtons(finalY1, finalY2); + } }); + }; - let overlay = svg.append('rect') - .attr('class', 'y-zoom-overlay') - .attr('x', 0) - .attr('y', gridTop) - .attr('width', overlayWidth) - .attr('height', gridBottom - gridTop) - .style('fill', 'transparent') - .call(drag); - - overlay.on('click', function() { - let clickY = clampY(d3.mouse(svg.node())[1]); - - if (pendingStartY === null) { - // First click - set first bound and start live preview - removeOverlays(); - startClickModeTracking(clickY); - } else { - // Second click: finalize the range - let firstY = pendingStartY; - cancelPendingClick(); - - let finalY1 = Math.min(firstY, clickY); - let finalY2 = Math.max(firstY, clickY); - if (finalY2 - finalY1 < 5) { return; } + // Left axis overlay + setupAxisOverlay('left', plot.scales.yLeft.scale, 0, gridLeft - 2, gridLeft + 5); - showZoomButtons(finalY1, finalY2); - } - }); + // Right axis overlay — only when a right scale exists + if (plot.scales.yRight && plot.scales.yRight.scale && plot.scales.yRight.scale.invert) { + let svgWidth = parseFloat(svg.attr('width')) || (gridRight + 80); + let rightOverlayX = gridRight + 2; + let rightOverlayW = Math.max(1, svgWidth - rightOverlayX); + // Buttons sit just inside the plot to the left of the right axis (Zoom 50px + gap 10px + Cancel 55px = 115px) + setupAxisOverlay('right', plot.scales.yRight.scale, rightOverlayX, rightOverlayW, gridRight - 120); + } if (this.getYZoomDomain && this.getYZoomDomain(plotId)) { + let zoomEntry = this.getYZoomDomain(plotId); let gridWidth = gridRight - gridLeft; let gridHeight = gridBottom - gridTop; let clipId = (plot.renderTo || plotId) + '-yzoom-clip'; @@ -1286,14 +1293,24 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { .attr('width', gridWidth).attr('height', gridHeight) .style({'fill': 'none', 'stroke': '#888', 'stroke-width': '1px', 'pointer-events': 'none'}); - svg.append('text') - .attr('class', 'qc-reset-zoom-link') - .text('Reset Zoom') - .attr('x', gridLeft + 5) - .attr('y', gridTop - 18) - .on('click', function() { - me.resetYZoom(plotId); - }); + if (zoomEntry.left) { + svg.append('text') + .attr('class', 'qc-reset-zoom-link') + .text('Reset Zoom') + .attr('x', gridLeft + 5) + .attr('y', gridTop - 18) + .on('click', function() { me.resetYZoom(plotId, 'left'); }); + } + + if (zoomEntry.right) { + svg.append('text') + .attr('class', 'qc-reset-zoom-link') + .text('Reset Zoom') + .attr('x', gridRight - 5) + .attr('y', gridTop - 18) + .style('text-anchor', 'end') + .on('click', function() { me.resetYZoom(plotId, 'right'); }); + } } } }); \ No newline at end of file diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 5092f8958..0841a6e65 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -2227,21 +2227,33 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { }, getYZoomDomain: function(plotId) { - return this.yZoomByPlot && this.yZoomByPlot[plotId] ? this.yZoomByPlot[plotId] : null; + let entry = this.yZoomByPlot && this.yZoomByPlot[plotId]; + if (!entry || (!entry.left && !entry.right)) return null; + return entry; }, - applyYZoom: function(plotId, yMin, yMax) { + applyYZoom: function(plotId, yMin, yMax, axis) { if (!this.yZoomByPlot) { this.yZoomByPlot = {}; } - this.yZoomByPlot[plotId] = [yMin, yMax]; + if (!this.yZoomByPlot[plotId]) { + this.yZoomByPlot[plotId] = {}; + } + this.yZoomByPlot[plotId][axis] = [yMin, yMax]; this.displayTrendPlot(true /* preserveZoom */); - // TODO: add server-side metric tracking action (targetedms/trackQCPlotAction.api) }, - resetYZoom: function(plotId) { - if (this.yZoomByPlot) { - delete this.yZoomByPlot[plotId]; + resetYZoom: function(plotId, axis) { + if (this.yZoomByPlot && this.yZoomByPlot[plotId]) { + if (axis) { + delete this.yZoomByPlot[plotId][axis]; + if (!this.yZoomByPlot[plotId].left && !this.yZoomByPlot[plotId].right) { + delete this.yZoomByPlot[plotId]; + } + } + else { + delete this.yZoomByPlot[plotId]; + } } this.displayTrendPlot(true /* preserveZoom */); }, From 8862e40f3494e5343df1a619ec665999329cefb6 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 26 May 2026 13:37:35 -0700 Subject: [PATCH 9/9] Block all plot interactions while zoom buttons are visible --- webapp/TargetedMS/js/QCPlotHelperBase.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js index 220987b7a..c4cb079fc 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -1105,6 +1105,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { let setupAxisOverlay = function(axis, yScale, overlayX, overlayW, btnAnchorX) { let dragStartY = null, dragCurrentY = null; let selectionRect = null, zoomButtonGroup = null, pendingLine = null, pendingStartY = null; + let interactionMask = null; let moveNs = 'mousemove.yzoom-' + axis; let keyNs = 'keydown.yzoom-' + axis; @@ -1112,6 +1113,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { if (selectionRect) { selectionRect.remove(); selectionRect = null; } if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } if (pendingLine) { pendingLine.remove(); pendingLine = null; } + if (interactionMask) { interactionMask.remove(); interactionMask = null; } }; let showZoomButtons = function(y1, y2) { @@ -1119,6 +1121,13 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { let domainMin = yScale.invert(y2); let yMid = y1 + (y2 - y1) / 2; + // Block all plot interactions while zoom buttons are visible + interactionMask = svg.append('rect') + .attr('x', 0).attr('y', 0) + .attr('width', parseFloat(svg.attr('width')) || (gridRight + 80)) + .attr('height', parseFloat(svg.attr('height')) || (gridBottom + 50)) + .style({'fill': 'transparent', 'pointer-events': 'all', 'cursor': 'default'}); + zoomButtonGroup = svg.append('g').attr('class', 'y-zoom-buttons'); let makeBtn = function(text, xLeft, width, onClick) {