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..da996694b 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: zoom-in; +} + +.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..e71bf4779 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)); + let 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)); + let 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,155 @@ 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 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 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; + } + }; + + let 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]); + + let y1 = Math.min(dragStartY, dragCurrentY); + let y2 = Math.max(dragStartY, dragCurrentY); + let 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', 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) { + removeOverlays(); + 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(); + }); + }); + + 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)) { + 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'}); + + 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); + }); + } } }); \ 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'); },