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"); diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css index b4434fed4..e63214c7c 100644 --- a/webapp/TargetedMS/css/qcTrendPlotReport.css +++ b/webapp/TargetedMS/css/qcTrendPlotReport.css @@ -115,4 +115,31 @@ .qc-combined-tree-legend .qc-tree-precursor:hover { background-color: #f0f0f0; +} + +.y-zoom-overlay { + 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); + 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..c4cb079fc 100644 --- a/webapp/TargetedMS/js/QCPlotHelperBase.js +++ b/webapp/TargetedMS/js/QCPlotHelperBase.js @@ -805,6 +805,12 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { } Ext4.apply(trendLineProps, this.getPlotTypeProperties(combinePlotData, plotType, isCUSUMMean, metricProps)); + let yZoomDomainCombined = this.getYZoomDomain ? this.getYZoomDomain(id) : null; + if (yZoomDomainCombined) { + if (yZoomDomainCombined.left) trendLineProps.yZoomDomain = yZoomDomainCombined.left; + if (yZoomDomainCombined.right) trendLineProps.yZoomDomainRight = yZoomDomainCombined.right; + } + // Suppress the mean line for multi-series plots trendLineProps.mean = undefined; @@ -860,6 +866,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 +952,12 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { Ext4.apply(trendLineProps, this.getPlotTypeProperties(precursorInfo, plotType, isCUSUMMean, metricProps)); + let yZoomDomain = this.getYZoomDomain ? this.getYZoomDomain(id) : null; + if (yZoomDomain) { + if (yZoomDomain.left) trendLineProps.yZoomDomain = yZoomDomain.left; + if (yZoomDomain.right) trendLineProps.yZoomDomainRight = yZoomDomain.right; + } + var plotLegendData = this.getAdditionalPlotLegend(plotType); if (Ext4.isArray(this.legendData)) { plotLegendData = plotLegendData.concat(this.legendData); @@ -1022,6 +1035,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 +1079,247 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", { showInPlotLegends: function () { return true; + }, + + addYZoomInteraction: function(plot, plotId) { + 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; + } + + let gridTop = grid.topEdge; + let gridBottom = grid.bottomEdge; + let gridLeft = grid.leftEdge; + let gridRight = grid.rightEdge; + + let clampY = function(y) { + return Math.max(gridTop, Math.min(gridBottom, y)); + }; + + // 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 interactionMask = 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; } + if (interactionMask) { interactionMask.remove(); interactionMask = null; } + }; + + let showZoomButtons = function(y1, y2) { + let domainMax = yScale.invert(y1); + 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) { + 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(); + }); + }; + + let cancelPendingClick = function() { + pendingStartY = null; + svg.on(moveNs, null); + d3.select(document).on(keyNs, 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(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; + + 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(keyNs, 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; + if (zoomButtonGroup) { zoomButtonGroup.remove(); zoomButtonGroup = null; } + }) + .on('drag', function() { + dragCurrentY = clampY(d3.mouse(svg.node())[1]); + + 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; + + if (h < 1) { return; } + + 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 (y2 - y1 < 5) { return; } + + 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 { + let firstY = pendingStartY; + cancelPendingClick(); + + let finalY1 = Math.min(firstY, clickY); + let finalY2 = Math.max(firstY, clickY); + if (finalY2 - finalY1 < 5) { return; } + + showZoomButtons(finalY1, finalY2); + } + }); + }; + + // Left axis overlay + setupAxisOverlay('left', plot.scales.yLeft.scale, 0, gridLeft - 2, gridLeft + 5); + + // 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'; + + let 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'}); + + 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 55ae8f4e2..7853bfd1a 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,38 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { } }, + getYZoomDomain: function(plotId) { + let entry = this.yZoomByPlot && this.yZoomByPlot[plotId]; + if (!entry || (!entry.left && !entry.right)) return null; + return entry; + }, + + applyYZoom: function(plotId, yMin, yMax, axis) { + if (!this.yZoomByPlot) { + this.yZoomByPlot = {}; + } + if (!this.yZoomByPlot[plotId]) { + this.yZoomByPlot[plotId] = {}; + } + this.yZoomByPlot[plotId][axis] = [yMin, yMax]; + this.processPlotData(); + }, + + 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.processPlotData(); + }, + getSvgElForPlot : function(plot) { return d3.select('#' + plot.renderTo + ' svg'); },