Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
55 changes: 55 additions & 0 deletions test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<QCPlot> 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");
Expand Down
21 changes: 21 additions & 0 deletions webapp/TargetedMS/css/qcTrendPlotReport.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
162 changes: 162 additions & 0 deletions webapp/TargetedMS/js/QCPlotHelperBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
});
}
}
});
27 changes: 25 additions & 2 deletions webapp/TargetedMS/js/QCTrendPlotPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
},
Expand Down
Loading