diff --git a/resources/psi-ms-PARSED.xml b/resources/psi-ms-PARSED.xml
index fa197bf67..66399935f 100644
--- a/resources/psi-ms-PARSED.xml
+++ b/resources/psi-ms-PARSED.xml
@@ -342,4 +342,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/org/labkey/targetedms/SkylineAuditLogManager.java b/src/org/labkey/targetedms/SkylineAuditLogManager.java
index e4d030c16..e3c5dbb85 100644
--- a/src/org/labkey/targetedms/SkylineAuditLogManager.java
+++ b/src/org/labkey/targetedms/SkylineAuditLogManager.java
@@ -593,7 +593,7 @@ public void testEntryRetrieval() throws AuditLogException
{
AuditLogTree node = new SkylineAuditLogManager(_container, null).buildLogTree(_docGUID);
while(node.iterator().hasNext()){
- AuditLogEntry ent = AuditLogEntry.retrieve(node.getEntryId());
+ AuditLogEntry ent = AuditLogEntry.retrieve(node.getEntryId(), _container);
assertNotNull(ent);
node = node.iterator().next();
}
diff --git a/src/org/labkey/targetedms/TargetedMSContainerScopingTest.java b/src/org/labkey/targetedms/TargetedMSContainerScopingTest.java
new file mode 100644
index 000000000..68c28986e
--- /dev/null
+++ b/src/org/labkey/targetedms/TargetedMSContainerScopingTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.labkey.targetedms;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.logging.log4j.Logger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.labkey.api.data.Container;
+import org.labkey.api.data.SimpleFilter;
+import org.labkey.api.data.Table;
+import org.labkey.api.data.TableSelector;
+import org.labkey.api.exp.api.ExpRun;
+import org.labkey.api.exp.api.ExperimentService;
+import org.labkey.api.pipeline.PipeRoot;
+import org.labkey.api.pipeline.PipelineService;
+import org.labkey.api.query.FieldKey;
+import org.labkey.api.security.permissions.AbstractContainerScopingTest;
+import org.labkey.api.util.GUID;
+import org.labkey.api.util.logging.LogHelper;
+import org.labkey.api.view.ActionURL;
+import org.labkey.api.view.ViewBackgroundInfo;
+import org.labkey.targetedms.parser.skyaudit.UnitTestUtil;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Container-scoping integration tests for {@link TargetedMSController} actions that resolve an object by a
+ * global id. Each test sets up the same object in one folder and confirms the action rejects a request that addresses
+ * via the wrong container
+ */
+public class TargetedMSContainerScopingTest extends AbstractContainerScopingTest
+{
+ private static final Logger LOG = LogHelper.getLogger(TargetedMSContainerScopingTest.class, "TargetedMS container scoping tests");
+ private static final GUID DOC_GUID = new GUID("8f1c2a44-3c5e-4f0a-9d2b-6e7a1b3c5d9e");
+
+ @Before
+ @After
+ public void cleanupAuditLog()
+ {
+ UnitTestUtil.cleanupDatabase(DOC_GUID);
+ }
+
+ /**
+ * RemoveLinkVersionAction relinks a Skyline document-version chain by run rowId. It must reject a run that lives in
+ * a different container (mirroring its sibling SaveLinkVersionsAction), otherwise an editor in one folder could
+ * rewrite the version chain of documents they can't see.
+ */
+ @Test
+ public void removeLinkVersionIsContainerScoped() throws Exception
+ {
+ Container owner = createContainer("LinkOwner");
+ Container other = createContainer("LinkOther");
+
+ // Negative: a run that lives in 'owner' cannot be relinked through the 'other' folder
+ ExpRun foreignRun = createRun(owner, "foreign-version");
+ ActionURL foreign = new ActionURL(TargetedMSController.RemoveLinkVersionAction.class, other)
+ .addParameter("rowId", foreignRun.getRowId());
+ assertStatus(HttpServletResponse.SC_BAD_REQUEST, post(foreign, getAdmin()));
+
+ // Positive: a run that lives in the acting folder is accepted
+ ExpRun localRun = createRun(other, "local-version");
+ ActionURL local = new ActionURL(TargetedMSController.RemoveLinkVersionAction.class, other)
+ .addParameter("rowId", localRun.getRowId());
+ assertStatus(HttpServletResponse.SC_OK, post(local, getAdmin()));
+ }
+
+ /**
+ * ShowSkylineAuditLogExtraInfoAJAXAction renders the full Skyline audit detail (user, timestamp, extra info) for an
+ * audit entry resolved by global EntryId. It must only render entries whose owning run is in the requested folder,
+ * otherwise any reader could read another folder's document audit history by guessing entry ids.
+ */
+ @Test
+ public void auditLogExtraInfoIsContainerScoped() throws Exception
+ {
+ Container owner = createContainer("AuditOwner");
+ Container other = createContainer("AuditOther");
+
+ int entryId = importAuditLogEntry(owner);
+
+ // Negative: the entry's run lives in 'owner', so requesting it via 'other' must 404 rather than leak detail
+ ActionURL foreign = new ActionURL(TargetedMSController.ShowSkylineAuditLogExtraInfoAJAXAction.class, other)
+ .addParameter("entryId", entryId);
+ assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreign, getAdmin()));
+
+ // Positive: requesting the entry through its owning folder renders normally
+ ActionURL owned = new ActionURL(TargetedMSController.ShowSkylineAuditLogExtraInfoAJAXAction.class, owner)
+ .addParameter("entryId", entryId);
+ assertStatus(HttpServletResponse.SC_OK, get(owned, getAdmin()));
+ }
+
+ /** Persist a minimal, saved {@link ExpRun} in the given container so the controller can resolve it by rowId. */
+ private ExpRun createRun(Container c, String name) throws Exception
+ {
+ ExperimentService svc = ExperimentService.get();
+ ExpRun run = svc.createExperimentRun(c, name);
+ PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(c);
+ assertNotNull("No pipeline root for " + c.getPath(), pipeRoot);
+ run.setFilePathRoot(pipeRoot.getRootPath());
+ run.setProtocol(svc.ensureSampleDerivationProtocol(getAdmin()));
+ ViewBackgroundInfo info = new ViewBackgroundInfo(c, getAdmin(), null);
+ return svc.saveSimpleExperimentRun(run, Collections.emptyMap(), Collections.emptyMap(),
+ Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(), info, null, false);
+ }
+
+ /** Insert a TargetedMS run in the given container, import a sample Skyline audit log for it, and return an EntryId. */
+ private int importAuditLogEntry(Container c) throws Exception
+ {
+ TargetedMSRun run = new TargetedMSRun();
+ run.setContainer(c);
+ run.setDocumentGUID(DOC_GUID);
+ Table.insert(getAdmin(), TargetedMSManager.getTableInfoRuns(), run);
+
+ File zip = UnitTestUtil.getSampleDataFile("AuditLogFiles/MethodEdit_v1.zip");
+ File logFile = UnitTestUtil.extractLogFromZip(zip, LOG);
+ new SkylineAuditLogManager(c, null).importAuditLogFile(getAdmin(), logFile, DOC_GUID, run);
+
+ List entryIds = new TableSelector(
+ TargetedMSManager.getTableInfoSkylineRunAuditLogEntry(),
+ Collections.singleton("AuditLogEntryId"),
+ new SimpleFilter(FieldKey.fromParts("VersionId"), run.getId()), null)
+ .getArrayList(Integer.class);
+ assertFalse("No audit log entries were imported", entryIds.isEmpty());
+ return entryIds.get(0);
+ }
+}
diff --git a/src/org/labkey/targetedms/TargetedMSController.java b/src/org/labkey/targetedms/TargetedMSController.java
index 4b2ff86c1..e7f80dcd9 100644
--- a/src/org/labkey/targetedms/TargetedMSController.java
+++ b/src/org/labkey/targetedms/TargetedMSController.java
@@ -3185,8 +3185,31 @@ public void addNavTrail(NavTree root)
}
}
+ /**
+ * Reading library spectra from a large library file can take many seconds over network storage, especially for large
+ * EncyclopeDIA libraries. To protect public folders from aggressive bots, do not show library spectra to guests when
+ * the library is large. Show the login prompt instead.
+ * Returns true (and adds the login view) when the library spectrum should be withheld.
+ */
+ private boolean addGuestSpectrumGate(TargetedMSRun run, VBox vbox)
+ {
+ if (!LibrarySpectrumMatchGetter.blockSpectraForGuest(getUser(), run.getId()))
+ {
+ return false;
+ }
+ HtmlView loginView = getLoginView(getViewContext(), getContainer());
+ loginView.setTitle("Library Spectrum");
+ loginView.setFrame(WebPartView.FrameType.PORTAL);
+ vbox.addView(loginView);
+ return true;
+ }
+
private void addSpectrumViews(TargetedMSRun run, VBox vbox, Precursor precursor, BindException errors)
{
+ if (addGuestSpectrumGate(run, vbox))
+ {
+ return;
+ }
PipeRoot root = PipelineService.get().getPipelineRootSetting(getContainer());
if (null != root)
{
@@ -3205,6 +3228,10 @@ private void addSpectrumViews(TargetedMSRun run, VBox vbox, Precursor precursor,
private void addSpectrumViews(TargetedMSRun run, VBox vbox, Peptide peptide, BindException errors)
{
+ if (addGuestSpectrumGate(run, vbox))
+ {
+ return;
+ }
PipeRoot root = PipelineService.get().getPipelineRootSetting(getContainer());
if (null != root)
{
@@ -3289,6 +3316,13 @@ public Object execute(SpectrumDataForm form, BindException errors)
}
TargetedMSRun run = TargetedMSManager.getRunForGeneralMolecule(peptide.getId());
+ // Apply the same guest gate as the spectrum views (see LibrarySpectrumMatchGetter.blockSpectraForGuest).
+ if (LibrarySpectrumMatchGetter.blockSpectraForGuest(getUser(), run.getId()))
+ {
+ response.put("error", "Login to view this data");
+ return response;
+ }
+
List libraries = LibraryManager.getLibraries(run.getId());
PeptideSettings.SpectrumLibrary library = null;
for (PeptideSettings.SpectrumLibrary lib : libraries)
@@ -5083,7 +5117,9 @@ public static class ShowSkylineAuditLogExtraInfoAJAXAction extends SimpleViewAct
@Override
public ModelAndView getView(SkylineAuditLogExtraInfoForm form, BindException errors)
{
- AuditLogEntry ent = AuditLogEntry.retrieve(form.getEntryId());
+ AuditLogEntry ent = AuditLogEntry.retrieve(form.getEntryId(), getContainer());
+ if (ent == null)
+ throw new NotFoundException("No audit log entry found for id " + form.getEntryId() + " in this folder.");
getPageConfig().setTemplate(PageConfig.Template.None);
return new JspView<>("/org/labkey/targetedms/view/skylineAuditLogExtraInfoView.jsp", ent);
}
@@ -7779,7 +7815,7 @@ public void validateForm(RowIdForm form, Errors errors)
// verify that the run rowId is valid and matches an existing run
// and if the run replaces any other runs, it should only replace one
ExpRun run = ExperimentService.get().getExpRun(form.getRowId());
- if (run == null)
+ if (run == null || !run.getContainer().equals(getContainer()))
errors.reject(ERROR_MSG, "No run found for id " + form.getRowId() + ".");
else if (!run.getReplacesRuns().isEmpty() && run.getReplacesRuns().size() > 1)
errors.reject(ERROR_MSG, "Run " + form.getRowId() + " replaces more than one run.");
diff --git a/src/org/labkey/targetedms/TargetedMSModule.java b/src/org/labkey/targetedms/TargetedMSModule.java
index 897b92bb8..686e6197a 100644
--- a/src/org/labkey/targetedms/TargetedMSModule.java
+++ b/src/org/labkey/targetedms/TargetedMSModule.java
@@ -693,7 +693,8 @@ protected void startupAfterSpringConfig(ModuleContext moduleContext)
{
return Set.of(
MsDataSourceUtil.TestCase.class,
- SkylineAuditLogManager.TestCase.class
+ SkylineAuditLogManager.TestCase.class,
+ TargetedMSContainerScopingTest.class
);
}
diff --git a/src/org/labkey/targetedms/chart/ChromatogramDataset.java b/src/org/labkey/targetedms/chart/ChromatogramDataset.java
index 91fd894cb..4f00f2372 100644
--- a/src/org/labkey/targetedms/chart/ChromatogramDataset.java
+++ b/src/org/labkey/targetedms/chart/ChromatogramDataset.java
@@ -900,6 +900,13 @@ public void build()
protected List getPeptideIdRetentionTimes()
{
+ // Skip peptide-ID retention-time markers for guests when the library is large. Reading large libraries can be slow over network storage.
+ // See LibrarySpectrumMatchGetter.blockSpectraForGuest.
+ if (LibrarySpectrumMatchGetter.blockSpectraForGuest(_user, _run.getId()))
+ {
+ return Collections.emptyList();
+ }
+
SampleFile sampleFile = ReplicateManager.getSampleFile(_pChromInfo.getSampleFileId());
// TODO: May want to move LocalDirectory up to controller, where others are created. Sharing probably desired.
diff --git a/src/org/labkey/targetedms/outliers/OutlierGenerator.java b/src/org/labkey/targetedms/outliers/OutlierGenerator.java
index ab76c9c05..94ea18405 100644
--- a/src/org/labkey/targetedms/outliers/OutlierGenerator.java
+++ b/src/org/labkey/targetedms/outliers/OutlierGenerator.java
@@ -29,6 +29,7 @@
import org.labkey.api.data.TableSelector;
import org.labkey.api.query.QueryService;
import org.labkey.api.security.User;
+import org.labkey.api.sql.LabKeySql;
import org.labkey.api.targetedms.model.SampleFileInfo;
import org.labkey.api.util.Pair;
import org.labkey.targetedms.TargetedMSManager;
@@ -130,7 +131,7 @@ else if (configuration.getAnnotationName() != null)
sql.append("(SELECT PrecursorChromInfoId, SampleFileId, ");
sql.append(" CAST(IFDEFINED(SeriesLabel) AS VARCHAR) AS SeriesLabel, ");
sql.append("\nMetricValue, ").append(configuration.getId()).append(" AS MetricId");
- sql.append("\n FROM ").append(schemaName).append('.').append(queryName);
+ sql.append("\n FROM ").append(schemaName).append('.').append(LabKeySql.quoteIdentifier(queryName));
sql.append(")");
}
return sql.toString();
diff --git a/src/org/labkey/targetedms/parser/skyaudit/AuditLogEntry.java b/src/org/labkey/targetedms/parser/skyaudit/AuditLogEntry.java
index 4e86d1aa5..8b7853b79 100644
--- a/src/org/labkey/targetedms/parser/skyaudit/AuditLogEntry.java
+++ b/src/org/labkey/targetedms/parser/skyaudit/AuditLogEntry.java
@@ -15,6 +15,7 @@
*/
package org.labkey.targetedms.parser.skyaudit;
+import org.labkey.api.data.Container;
import org.labkey.api.data.SimpleFilter;
import org.labkey.api.data.Table;
import org.labkey.api.data.TableSelector;
@@ -22,6 +23,7 @@
import org.labkey.api.security.User;
import org.labkey.api.util.GUID;
import org.labkey.targetedms.TargetedMSManager;
+import org.labkey.targetedms.TargetedMSRun;
import java.math.BigDecimal;
import java.math.BigInteger;
@@ -70,13 +72,25 @@ public AuditLogEntry(BigDecimal documentFormatVersion)
_documentFormatVersion = documentFormatVersion;
}
- public static AuditLogEntry retrieve(int pEntryId)
+ /**
+ * Container-scoped retrieve. Returns the entry only if its owning Skyline run lives in the supplied container,
+ * preventing callers from reading audit detail for documents in folders the user can't access. An entryId can
+ * map to more than one run when documents share an audit history, so check every match for one in the container.
+ */
+ public static AuditLogEntry retrieve(int pEntryId, Container container)
{
TableSelector sel = new TableSelector(TargetedMSManager.getTableInfoSkylineAuditLog(), new SimpleFilter(FieldKey.fromParts("EntryId"), pEntryId), null);
- List results = sel.getArrayList(AuditLogEntry.class);
- // Possible to get more than one match if two documents share an audit history. In this case, we don't care
- // which we use
- return results.isEmpty() ? null : results.getFirst();
+ for (AuditLogEntry entry : sel.getArrayList(AuditLogEntry.class))
+ {
+ Long runId = entry.getRunId();
+ if (runId != null)
+ {
+ TargetedMSRun run = TargetedMSManager.getRun(runId);
+ if (run != null && container.equals(run.getContainer()))
+ return entry;
+ }
+ }
+ return null;
}
public AuditLogTree getTreeEntry(){
diff --git a/src/org/labkey/targetedms/view/precursorConflictResolutionView.jsp b/src/org/labkey/targetedms/view/precursorConflictResolutionView.jsp
index 49be45ee2..a38695c88 100644
--- a/src/org/labkey/targetedms/view/precursorConflictResolutionView.jsp
+++ b/src/org/labkey/targetedms/view/precursorConflictResolutionView.jsp
@@ -119,12 +119,12 @@ $(document).ready(function () {
{
row.child.hide();
tr.removeClass('shown');
- $("." + cls).children('img').attr('src', "<%=getWebappURL("_images/plus.gif")%>");
+ $("." + cls).children('img').attr('src', "<%=getWebappURL("_images/plus.gif")%>").attr('alt', 'Expand row details');
}
else {
row.child.show();
tr.addClass('shown');
- $("." + cls).children('img').attr('src', "<%=getWebappURL("_images/minus.gif")%>");
+ $("." + cls).children('img').attr('src', "<%=getWebappURL("_images/minus.gif")%>").attr('alt', 'Collapse row details');
}
if(!srcTd.hasClass('content_loaded'))
@@ -271,7 +271,7 @@ function toggleCheckboxSelection(element)
- "/>
+ " alt="Expand row details"/>
@@ -291,7 +291,7 @@ function toggleCheckboxSelection(element)
- "/>
+ " alt="Expand row details"/>
diff --git a/src/org/labkey/targetedms/view/spectrum/LibrarySpectrumMatchGetter.java b/src/org/labkey/targetedms/view/spectrum/LibrarySpectrumMatchGetter.java
index a7057b4af..94ea34f17 100644
--- a/src/org/labkey/targetedms/view/spectrum/LibrarySpectrumMatchGetter.java
+++ b/src/org/labkey/targetedms/view/spectrum/LibrarySpectrumMatchGetter.java
@@ -16,6 +16,7 @@
package org.labkey.targetedms.view.spectrum;
import org.apache.commons.io.FilenameUtils;
+import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.labkey.api.cache.BlockingCache;
@@ -24,6 +25,7 @@
import org.labkey.api.data.Container;
import org.labkey.api.security.User;
import org.labkey.api.util.FileUtil;
+import org.labkey.api.util.logging.LogHelper;
import org.labkey.targetedms.TargetedMSManager;
import org.labkey.targetedms.TargetedMSRun;
import org.labkey.targetedms.TargetedMSSchema;
@@ -42,6 +44,8 @@
import org.labkey.targetedms.query.PeptideManager;
import org.labkey.targetedms.query.PrecursorManager;
+import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.SQLException;
import java.util.ArrayList;
@@ -58,8 +62,65 @@
*/
public class LibrarySpectrumMatchGetter
{
+ private static final Logger LOG = LogHelper.getLogger(LibrarySpectrumMatchGetter.class, "Matches library spectra and retention times for the library spectrum viewer");
+
private static final int CACHE_SIZE = 10;
+ // Reading library spectra and retention times from large spectrum libraries can be slow over network storage.
+ // For EncyclopeDIA .elib we read one row per source file for the peptide. This can be hundreds of rows and the needed
+ // columns are not in the index, so each table row lookup is a separate network round-trip on GPFS.
+ // For BiblioSpec .blib we scan the unindexed RetentionTimes table for the RT of the peptide in all the scans and source
+ // files.
+ // PanoramaWeb has large files of both types, so the size gate covers both library types. To protect public folders from
+ // aggressive bots, library spectra are not shown to guests when the library file is at or above this size. Guests are
+ // asked to log in instead.
+ private static final long GUEST_SPECTRUM_LIBRARY_SIZE_LIMIT = 500L * 1024 * 1024; // 500 MB
+
+ /**
+ * Returns true if library spectra should NOT be shown to the given user for the given run,
+ * i.e. the user is a guest and the run references a supported spectrum library file that is at
+ * or above {@link #GUEST_SPECTRUM_LIBRARY_SIZE_LIMIT}. Logged-in users are never blocked, and
+ * small libraries are read in place as before.
+ */
+ public static boolean blockSpectraForGuest(User user, long runId)
+ {
+ if (!user.isGuest())
+ {
+ return false;
+ }
+ for (Path libPath : LibraryManager.getLibraryFilePaths(runId).values())
+ {
+ if (isLargeSpectrumLibrary(libPath))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isLargeSpectrumLibrary(Path libPath)
+ {
+ // Only .elib/.blib libraries are read for spectra; ignore anything we cannot read.
+ if (libPath == null || getReaderForLibrary(FileUtil.getFileName(libPath)) == null)
+ {
+ return false;
+ }
+ try
+ {
+ // Files.size throws NoSuchFileException if the file is missing, so a separate Files.exists
+ // check is unnecessary and would add a second filesystem round-trip on network storage.
+ return Files.size(libPath) >= GUEST_SPECTRUM_LIBRARY_SIZE_LIMIT;
+ }
+ catch (IOException e)
+ {
+ // The file is missing or unreadable; the downstream library read would fail too.
+ // Log a single WARN line and keep the stack trace at DEBUG so frequent hits do not flood the primary log.
+ LOG.warn("Could not determine size of spectrum library file " + libPath + " (" + e.getClass().getSimpleName() + ")");
+ LOG.debug("Could not determine size of spectrum library file " + libPath, e);
+ return false;
+ }
+ }
+
private static final BlockingCache> _peptideIdRtsCache =
CacheManager.getBlockingCache(CACHE_SIZE, CacheManager.DAY, "TargetedMS peptide ID retention times",
(precursor, argument) -> {
diff --git a/test/sampledata/.DS_Store b/test/sampledata/.DS_Store
new file mode 100644
index 000000000..19666aa09
Binary files /dev/null and b/test/sampledata/.DS_Store differ
diff --git a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java
index 67027e380..b91080cb2 100644
--- a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java
+++ b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java
@@ -901,6 +901,36 @@ public String toString()
}
}
+ public void performYAxisZoom(QCPlot qcPlot)
+ {
+ WebElement plotEl = qcPlot.getPlot();
+ WebElement overlay = elementCache().yZoomOverlay.findElement(plotEl);
+ getWrapper().scrollIntoView(overlay);
+
+ int clickOffset = 40;
+ new Actions(getWrapper().getDriver())
+ .moveToElement(overlay, 0, -clickOffset)
+ .click()
+ .moveToElement(overlay, 0, clickOffset)
+ .click()
+ .perform();
+
+ WebDriverWrapper.waitFor(() -> !elementCache().yZoomConfirmBtn.findElements(plotEl).isEmpty(),
+ "Zoom buttons did not appear after y-axis clicks", WAIT_FOR_JAVASCRIPT);
+
+ elementCache().yZoomConfirmBtn.findElement(plotEl).click();
+ }
+
+ public boolean isZoomActive(QCPlot qcPlot)
+ {
+ return !elementCache().yZoomBorder.findElements(qcPlot.getPlot()).isEmpty();
+ }
+
+ public void clickResetZoom(QCPlot qcPlot)
+ {
+ elementCache().yZoomOverlay.findElement(qcPlot.getPlot()).click();
+ }
+
public class Elements extends BodyWebPart>.ElementCache
{
WebElement startDate = Locator.css("#start-date-field input").findWhenNeeded(this);
@@ -936,6 +966,9 @@ 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 yZoomConfirmBtn = Locator.css("svg g.y-zoom-btn-zoom rect");
+ Locator.CssLocator yZoomBorder = Locator.css("svg rect.y-zoom-border");
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 0d61e493a..45a78a539 100644
--- a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java
+++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java
@@ -1147,6 +1147,56 @@ 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, confirm zoom, border appears
+ 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));
+
+ // 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));
+
+ // 3. Verify reset works: clicking the zoomed y-axis (zoom-out cursor) resets zoom
+ log("Verifying clicking the y-axis resets 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));
+
+ // 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();
+
+ plots = qcPlotsWebPart.getPlots();
+ firstPlot = plots.get(0);
+
+ assertFalse("Zoom should not persist after page reload", qcPlotsWebPart.isZoomActive(firstPlot));
+ }
+
private void createAndInsertAnnotations()
{
clickTab("Annotations");
diff --git a/test/src/org/labkey/test/tests/targetedms/passport/PassportTest.java b/test/src/org/labkey/test/tests/targetedms/passport/PassportTest.java
index 29ebf2937..aef224bf0 100644
--- a/test/src/org/labkey/test/tests/targetedms/passport/PassportTest.java
+++ b/test/src/org/labkey/test/tests/targetedms/passport/PassportTest.java
@@ -24,6 +24,8 @@
import java.util.List;
+import static org.junit.Assert.assertTrue;
+
/**
* Tests Panorama Passport integration end-to-end, covering user management, permission setup, and project
* configuration for both normal and admin users.
@@ -95,13 +97,20 @@ private void testNormalStuff()
waitForElement(Locator.tagWithId("span", "filteredPeptideCount").childTag("green"));
assertElementContains(Locator.xpath("//span[@id='filteredPeptideCount']//green"), "19");
- //features
- waitForElements(Locator.tagWithClass("td", "feature-sequencevariant"), 5);
- waitForElements(Locator.tagWithClass("td", "feature-glycosylationsite"), 4);
- waitForElements(Locator.tagWithClass("td", "feature-helix"), 7);
- waitForElements(Locator.tagWithClass("td", "feature-turn"), 6);
+ // Features come from a live query against Uniprot, so be tolerant of any number above our baseline
+ waitForElementsAtLeast(Locator.tagWithClass("td", "feature-sequencevariant"), 3);
+ waitForElementsAtLeast(Locator.tagWithClass("td", "feature-glycosylationsite"), 3);
+ waitForElementsAtLeast(Locator.tagWithClass("td", "feature-helix"), 3);
+ waitForElementsAtLeast(Locator.tagWithClass("td", "feature-turn"), 3);
}
+ public void waitForElementsAtLeast(final Locator loc, final int count)
+ {
+ waitFor(() -> loc.findElements(getDriver()).size() >= count, WAIT_FOR_JAVASCRIPT);
+ assertTrue("Element not present at least the expected number of times", loc.findElements(getDriver()).size() >= count);
+ }
+
+
@LogMethod
protected void testAsNormalUser()
{
diff --git a/webapp/TargetedMS/css/qcTrendPlotReport.css b/webapp/TargetedMS/css/qcTrendPlotReport.css
index b4434fed4..2efcc589a 100644
--- a/webapp/TargetedMS/css/qcTrendPlotReport.css
+++ b/webapp/TargetedMS/css/qcTrendPlotReport.css
@@ -70,6 +70,7 @@
font-size: 18px;
padding: 0 8px;
border: solid #c0c0c0 1px;
+ background: none;
}
.qc-paging-prev {
border-right-width: 0;
@@ -115,4 +116,24 @@
.qc-combined-tree-legend .qc-tree-precursor:hover {
background-color: #f0f0f0;
-}
\ No newline at end of file
+}
+
+.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;
+}
diff --git a/webapp/TargetedMS/js/QCPlotHelperBase.js b/webapp/TargetedMS/js/QCPlotHelperBase.js
index 6b974b284..0f7a745a6 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,252 @@ 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));
+ };
+
+ let zoomEntry = this.getYZoomDomain ? this.getYZoomDomain(plotId) : 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 isZoomed = !!(zoomEntry && zoomEntry[axis]);
+
+ 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', 'cursor': isZoomed ? 'zoom-out' : 'zoom-in'});
+
+ if (isZoomed) {
+ overlayEl.on('click', function() { me.resetYZoom(plotId, axis); });
+ return;
+ }
+
+ let dragStartY = null, dragCurrentY = null;
+ let selectionRect = null, zoomButtonGroup = null, pendingLine = null, pendingStartY = null;
+ let interactionMask = null, plotClickCapture = 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; }
+ if (plotClickCapture) { plotClickCapture.remove(); plotClickCapture = 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').attr('class', 'y-zoom-btn-' + text.toLowerCase());
+ 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();
+ }
+ });
+
+ plotClickCapture = svg.append('rect')
+ .attr('x', gridLeft).attr('y', gridTop)
+ .attr('width', gridRight - gridLeft).attr('height', gridBottom - gridTop)
+ .style({'fill': 'transparent', 'cursor': 'crosshair'})
+ .on('click', function() {
+ let clickY = clampY(d3.mouse(svg.node())[1]);
+ let firstY = pendingStartY;
+ cancelPendingClick();
+ let finalY1 = Math.min(firstY, clickY);
+ let finalY2 = Math.max(firstY, clickY);
+ if (finalY2 - finalY1 < 5) { return; }
+ showZoomButtons(finalY1, finalY2);
+ });
+ };
+
+ 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);
+ });
+
+ overlayEl.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 (zoomEntry) {
+ 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'});
+ }
}
});
\ No newline at end of file
diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js
index 5760ce8bb..12f03a399 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,
@@ -1020,8 +1021,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
columns: 2,
vertical: false,
items: [
- { boxLabel: 'per replicate', id: 'x-axis-grouping-replicate', name: 'xAxisGrouping', inputValue: 'replicate', checked: this.groupedX === false },
- { boxLabel: 'per date', id: 'x-axis-grouping-date', name: 'xAxisGrouping', inputValue: 'date', checked: this.groupedX === true }
+ { boxLabel: 'per replicate', id: 'x-axis-grouping-replicate', name: 'xAxisGrouping', inputValue: 'replicate', checked: this.groupedX === false, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'X-axis grouping: per replicate'}); } } },
+ { boxLabel: 'per date', id: 'x-axis-grouping-date', name: 'xAxisGrouping', inputValue: 'date', checked: this.groupedX === true, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'X-axis grouping: per date'}); } } }
],
listeners: {
scope: this,
@@ -1050,8 +1051,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
columns: 2,
vertical: false,
items: [
- { boxLabel: 'per precursor', name: 'showPlots', id: 'plots-per-precursor', inputValue: 'per-precursor', checked: this.singlePlot === false },
- { boxLabel: 'combined', name: 'showPlots', id: 'plots-combined', inputValue: 'combined', checked: this.singlePlot === true }
+ { boxLabel: 'per precursor', name: 'showPlots', id: 'plots-per-precursor', inputValue: 'per-precursor', checked: this.singlePlot === false, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Plots: per precursor'}); } } },
+ { boxLabel: 'combined', name: 'showPlots', id: 'plots-combined', inputValue: 'combined', checked: this.singlePlot === true, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Plots: combined'}); } } }
],
listeners: {
scope: this,
@@ -1081,8 +1082,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
columns: 2,
vertical: false,
items: [
- { boxLabel: 'show', id: 'excluded-replicates-show', name: 'excludedSamples', inputValue: 'show', checked: this.showExcluded === true },
- { boxLabel: 'hide', id: 'excluded-replicates-hide', name: 'excludedSamples', inputValue: 'hide', checked: this.showExcluded === false }
+ { boxLabel: 'show', id: 'excluded-replicates-show', name: 'excludedSamples', inputValue: 'show', checked: this.showExcluded === true, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Excluded replicates: show'}); } } },
+ { boxLabel: 'hide', id: 'excluded-replicates-hide', name: 'excludedSamples', inputValue: 'hide', checked: this.showExcluded === false, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Excluded replicates: hide'}); } } }
],
listeners: {
scope: this,
@@ -1110,8 +1111,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
columns: 2,
vertical: false,
items: [
- { boxLabel: 'show', id: 'excluded-precursors-show', name: 'excludedPrecursors', inputValue: 'show', checked: this.showExcludedPrecursors === true },
- { boxLabel: 'hide', id: 'excluded-precursors-hide', name: 'excludedPrecursors', inputValue: 'hide', checked: this.showExcludedPrecursors === false }
+ { boxLabel: 'show', id: 'excluded-precursors-show', name: 'excludedPrecursors', inputValue: 'show', checked: this.showExcludedPrecursors === true, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Excluded precursors: show'}); } } },
+ { boxLabel: 'hide', id: 'excluded-precursors-hide', name: 'excludedPrecursors', inputValue: 'hide', checked: this.showExcludedPrecursors === false, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Excluded precursors: hide'}); } } }
],
listeners: {
scope: this,
@@ -1144,8 +1145,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
columns: 2,
vertical: false,
items: [
- { boxLabel: 'always show', id: 'reference-guide-set-show', name: 'referenceGuideSets', inputValue: 'show', checked: this.showReferenceGS === true },
- { boxLabel: 'when in date range', id: 'reference-guide-set-hide', name: 'referenceGuideSets', inputValue: 'hide', checked: this.showReferenceGS === false }
+ { boxLabel: 'always show', id: 'reference-guide-set-show', name: 'referenceGuideSets', inputValue: 'show', checked: this.showReferenceGS === true, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Reference guide sets: always show'}); } } },
+ { boxLabel: 'when in date range', id: 'reference-guide-set-hide', name: 'referenceGuideSets', inputValue: 'hide', checked: this.showReferenceGS === false, listeners: { afterrender: function(r) { r.inputEl.set({'aria-label': 'Reference guide sets: when in date range'}); } } }
],
listeners: {
scope: this,
@@ -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();
@@ -1283,11 +1286,11 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
getPaginationBtns: function(numOfPrecursors) {
var btnHtml = '';
- btnHtml += '';
+ btnHtml += '';
- btnHtml += '';
+ btnHtml += '';
return btnHtml;
},
@@ -1506,7 +1509,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
pathMouseOver : function(event, pathData, layerSel, path, valueName, config) {
if (pathData.group) {
- this.highlightFragmentSeries(pathData.group);
+ // pass base fragment, like the other highlight triggers
+ this.highlightFragmentSeries(pathData.group.split(LABKEY.targetedms.QCPlotHelperBase.SERIES_NAME_SEP)[0]);
}
},
@@ -2066,6 +2070,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
let label = checkbox.closest('label');
if (label) {
label.addEventListener('mouseenter', function() {
+ clearTimeout(precursorLeaveTimer); // cancel a pending precursor reset when moving sub-item -> header
let hidden = me.hiddenPrecursorSeries || {};
let hiddenFragments = group.fragments.filter(function(f) { return !!hidden[f]; });
if (hiddenFragments.length > 0) {
@@ -2223,6 +2228,52 @@ 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;
+ },
+
+ incrementMetric: function(metricName) {
+ if (LABKEY.user && LABKEY.user.isGuest) {
+ return;
+ }
+ LABKEY.Ajax.request({
+ url: LABKEY.ActionURL.buildURL('core', 'incrementClientSideMetricCount.api'),
+ method: 'POST',
+ jsonData: { featureArea: 'panoramaQCPlot', metricName: metricName },
+ failure: function(response) { console.error('Failed to track metric ' + metricName + ':', response); }
+ });
+ },
+
+ 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.incrementMetric('yAxisZoom');
+ 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.incrementMetric('yAxisZoomReset');
+ this.processPlotData();
+ },
+
getSvgElForPlot : function(plot) {
return d3.select('#' + plot.renderTo + ' svg');
},
@@ -2498,72 +2549,6 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
return '#' + d['Color'];
};
- let annotations = this.getSvgElForPlot(plot).selectAll("path.annotation").data(this.annotationData)
- .enter().append("path").attr("class", "annotation")
- .attr("d", this.annotationShape(4)).attr('transform', transformAcc)
- .style("fill", colorAcc).style("stroke", colorAcc);
-
- // add mouseover effects for fun
- let mouseOn = function(pt, strokeWidth, d) {
- d3.select(pt).transition().duration(800).attr("stroke-width", strokeWidth).ease("elastic");
-
- if (!pt._tippy) {
- let date = new Date(d['Date']);
- let dateStr = me.formatDate(date, date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0);
- let content = "