diff --git a/resources/views/configureQCMetric.html b/resources/views/configureQCMetric.html index 0d65d3017..565e98994 100644 --- a/resources/views/configureQCMetric.html +++ b/resources/views/configureQCMetric.html @@ -230,7 +230,7 @@ const windowConfig = { parent: this, - traces: tracesPresent ? traces : {} , + traces: tracesPresent ? traces : [] , tracesPresent: tracesPresent, operation: op } @@ -239,7 +239,7 @@ windowConfig.metric = clickedMetric; } - Ext4.create('Panorama.Window.AddTraceMetricWindow', windowConfig).show(); + Panorama.Window.AddTraceMetricWindow.show(windowConfig); } }); }, diff --git a/resources/views/configureQCMetric.view.xml b/resources/views/configureQCMetric.view.xml index 69c26d2dd..196ac6f01 100644 --- a/resources/views/configureQCMetric.view.xml +++ b/resources/views/configureQCMetric.view.xml @@ -4,6 +4,8 @@ + diff --git a/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js index 32b44aed4..1115fd637 100644 --- a/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js +++ b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js @@ -13,9 +13,28 @@ let _allAnnotations = []; function closeDialog() { + $(document).off('keydown.lkAnnotationMetric'); $('#' + DIALOG_ID).remove(); } + // Keep Tab inside the modal + function trapFocus(e) { + const $focusable = $('#' + DIALOG_ID).find('input, select, button').filter(':visible').not(':disabled'); + if ($focusable.length === 0) { + return; + } + const first = $focusable[0]; + const last = $focusable[$focusable.length - 1]; + if (e.shiftKey && e.target === first) { + e.preventDefault(); + last.focus(); + } + else if (!e.shiftKey && e.target === last) { + e.preventDefault(); + first.focus(); + } + } + function showError(msg) { $('#lk-annotation-metric-error').text(msg).show(); } @@ -162,10 +181,13 @@ const isPrecursor = op === 'update' && metric.PrecursorScoped; const title = op === 'insert' ? 'Add Annotation-Backed Metric' : 'Edit Annotation-Backed Metric'; - return '
' + return ''; + } + + window.Panorama.Window.AddTraceMetricWindow = { + show: function(config) { + _config = config; + _busy = false; + + closeDialog(); + $('body').append(buildDialogHtml()); + + $('#lk-trace-metric-cancel').on('click', closeDialog); + $('#lk-trace-metric-save').on('click', save); + if (config.operation === 'update') { + $('#lk-trace-metric-delete').on('click', deleteMetric); + } + $('input[name="metricValue"]').on('change', refreshMode); + + // close on overlay click + $('#' + DIALOG_ID).on('click', function(e) { + if (e.target === this && !_busy) closeDialog(); + }); + + refreshMode(); + + $('#lk-trace-metric-name').trigger('focus'); + + $(document).on('keydown.lkTraceMetric', function(e) { + if (e.key === 'Escape' && !_busy) { + closeDialog(); + } + else if (e.key === 'Tab') { + trapFocus(e); + } + }); + } + }; +})(jQuery); diff --git a/src/org/labkey/targetedms/view/calibrationCurve.jsp b/src/org/labkey/targetedms/view/calibrationCurve.jsp index 1fefd7f5e..8b5e9ba69 100644 --- a/src/org/labkey/targetedms/view/calibrationCurve.jsp +++ b/src/org/labkey/targetedms/view/calibrationCurve.jsp @@ -30,7 +30,6 @@ @Override public void addClientDependencies(ClientDependencies dependencies) { - dependencies.add("Ext4"); dependencies.add("vis/vis"); dependencies.add("targetedms/js/CalibrationCurve.js"); dependencies.add("targetedms/css/CalibrationCurve.css"); @@ -46,9 +45,9 @@ var calibrationCurvePlot; - Ext4.onReady(function () { + LABKEY.Utils.onReady(function () { - calibrationCurvePlot = Ext4.create('LABKEY.targetedms.CalibrationCurve', { + calibrationCurvePlot = new LABKEY.targetedms.CalibrationCurve({ renderTo: <%=q(elementId)%>, data: <%=bean%> }); diff --git a/src/org/labkey/targetedms/view/paretoPlot.jsp b/src/org/labkey/targetedms/view/paretoPlot.jsp index 6282a42fe..18d72102a 100644 --- a/src/org/labkey/targetedms/view/paretoPlot.jsp +++ b/src/org/labkey/targetedms/view/paretoPlot.jsp @@ -27,11 +27,9 @@ @Override public void addClientDependencies(ClientDependencies dependencies) { - dependencies.add("Ext4"); dependencies.add("vis/vis"); dependencies.add("targetedms/css/SVGExportIcon.css"); dependencies.add("targetedms/css/ParetoPlot.css"); - dependencies.add("targetedms/js/BaseQCPlotPanel.js"); dependencies.add("targetedms/js/ParetoPlotPanel.js"); dependencies.add("targetedms/js/QCMetricConfigLoader.js"); } @@ -47,22 +45,11 @@ function init() { var tiledPlotPanelId = <%=q(tiledPlotPanelId)%>; - if (Ext4.isIE8) { - Ext4.get(tiledPlotPanelId).update("Unable to render report in Internet Explorer < 9."); - return; - } - - initializeParetoPlotPanel(tiledPlotPanelId); - } - - function initializeParetoPlotPanel(tiledPlotPanelId) { - - // initialize the panel that displays Pareto plot - Ext4.create('LABKEY.targetedms.ParetoPlotPanel', { - cls: 'themed-panel', + // initialize the panel that displays Pareto plots + new LABKEY.targetedms.ParetoPlotPanel({ plotDivId: tiledPlotPanelId }); } - Ext4.onReady(init); + LABKEY.Utils.onReady(init); diff --git a/src/org/labkey/targetedms/view/summaryChartsView.jsp b/src/org/labkey/targetedms/view/summaryChartsView.jsp index 2156b8af7..26b068b6d 100644 --- a/src/org/labkey/targetedms/view/summaryChartsView.jsp +++ b/src/org/labkey/targetedms/view/summaryChartsView.jsp @@ -30,7 +30,6 @@ @Override public void addClientDependencies(ClientDependencies dependencies) { - dependencies.add("Ext4"); dependencies.add("internal/jQuery"); dependencies.add("TargetedMS/js/svgChart.js"); dependencies.add("TargetedMS/css/svgChart.css"); @@ -62,7 +61,9 @@ long moleculeId = bean.getMoleculeId(); long moleculePrecursorId = bean.getMoleculePrecursorId(); - if ((peptideList != null && !peptideList.isEmpty()) || peptideId != 0 || precursorId != 0) + boolean asProteomics = (peptideList != null && !peptideList.isEmpty()) || peptideId != 0 || precursorId != 0; + + if (asProteomics) { peakAreaUrl.addParameter("asProteomics", true); retentionTimesUrl.addParameter("asProteomics", true); @@ -98,6 +99,15 @@ peakAreaUrl.addParameter("chartHeight", bean.getInitialHeight()); retentionTimesUrl.addParameter("chartWidth", bean.getInitialWidth()); retentionTimesUrl.addParameter("chartHeight", bean.getInitialHeight()); + + boolean hasPeptides = peptideList != null && !peptideList.isEmpty(); + boolean hasMolecules = moleculeList != null && !moleculeList.isEmpty(); + boolean showReplicate = replicateList.size() > 1; + boolean showAnnotName = !replicateAnnotationNameList.isEmpty(); + boolean showAnnotValue = !replicateAnnotationValueList.isEmpty(); + boolean showPeptide = hasPeptides && peptideList.size() > 1; + boolean showMolecule = hasMolecules && moleculeList.size() > 1; + boolean showCv = showReplicate || showAnnotName; %> +
+
;"> + + ;"> + + + + ;"> + + + + ;"> + + + + ;"> + + + + ;"> + + + + ;"> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
Value only affects Retention Times chart.
+
+
+

Peak Areas

+
+
+
+
+

Retention Times

+
+
+
+
-
-
;">
-
-

Peak Areas

-
-
-
-
-

Retention Times

-
-
-
-
- diff --git a/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java b/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java index 77aeb2957..4fba65d89 100644 --- a/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java +++ b/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java @@ -18,10 +18,8 @@ import org.jetbrains.annotations.Nullable; import org.junit.Assert; import org.labkey.test.Locator; -import org.labkey.test.components.ext4.Window; import org.labkey.test.components.targetedms.QCPlotsWebPart; import org.labkey.test.pages.PortalBodyPanel; -import org.labkey.test.util.Ext4Helper; import org.openqa.selenium.WebDriver; import java.util.Map; @@ -153,9 +151,8 @@ public void addNewCustomMetric(Map metricPropert public void addNewTraceMetric(Map traceProperties, boolean duplicateNameErrorExpected) { click(Locator.tagWithText("button", "Add New Trace Metric")); - waitForElement(Ext4Helper.Locators.window("Add New Trace Metric")); - Window metricWindow = new Window.WindowFinder(getDriver()).withTitle("Add New Trace Metric").waitFor(); - editTraceMetricValues(metricWindow, traceProperties, duplicateNameErrorExpected); + waitForTraceMetricDialog(); + editTraceMetricValues(traceProperties, duplicateNameErrorExpected); } public void addNewAnnotationMetric(Map metricProperties, boolean duplicateNameErrorExpected) @@ -234,32 +231,63 @@ private void fillCustomMetricForm(Map props) selectOptionByText(Locator.id("lk-custom-metric-query"), props.get(CustomMetricProperties.queryName)); } - private void editTraceMetricValues(Window metricWindow, Map metricProperties, boolean duplicateNameErrorExpected) + public void editTraceMetric(String metric, Map metricProperties) { + waitAndClick(Locator.linkWithText(metric)); + waitForTraceMetricDialog(); + editTraceMetricValues(metricProperties, false); + } + + /** + * Opens the metric for edit, reports which mode radio the dialog came up in, and closes it again without saving. + * @return "timeValue" or "traceValue" + */ + public String getTraceMetricMode(String metric) + { + waitAndClick(Locator.linkWithText(metric)); + waitForTraceMetricDialog(); + String mode = Locator.css("input[name='metricValue']:checked").findElement(getDriver()).getAttribute("value"); + click(Locator.id("lk-trace-metric-cancel")); + waitForElementToDisappear(Locator.id("lk-trace-metric-dialog")); + return mode; + } + + private void waitForTraceMetricDialog() + { + waitForElement(Locator.id("lk-trace-metric-dialog")); + // wait on the select itself, not its placeholder option: with no traces in the container the + // dialog renders a disabled "No trace can be found" select instead + waitForElement(Locator.id("lk-trace-use-trace")); + } + + private void editTraceMetricValues(Map metricProperties, boolean duplicateNameErrorExpected) + { + // each mode's fields are only enabled when its radio button is selected, so pick the mode + // before filling anything in + if (metricProperties.containsKey(TraceMetricProperties.traceValue)) + click(Locator.css("input[name='metricValue'][value='traceValue']")); + else + click(Locator.css("input[name='metricValue'][value='timeValue']")); + metricProperties.forEach((prop, val) -> { - if (!prop.isSelect) - { - setFormElement(Locator.name(prop.name()), val); - } - else if (prop.formLabel != null) - { - _ext4Helper.selectComboBoxItem(prop.formLabel, val); - } + if (prop.isSelect) + selectOptionByText(Locator.id(prop.elementId), val); else - { - _ext4Helper.selectComboBoxItem(prop.loc, val); - } + setFormElement(Locator.id(prop.elementId), val); }); + if (duplicateNameErrorExpected) { - click(Ext4Helper.Locators.ext4Button("Save")); - assertTextPresent("A metric with the name \"" + metricProperties.get(ConfigureMetricsUIPage.TraceMetricProperties.metricName) + "\" already exists. Please choose a different name."); - click(Ext4Helper.Locators.ext4Button("Cancel")); + click(Locator.id("lk-trace-metric-save")); + assertTextPresent("A metric with the name \"" + metricProperties.get(TraceMetricProperties.metricName) + "\" already exists. Please choose a different name."); + click(Locator.id("lk-trace-metric-cancel")); } else { - clickAndWait(Ext4Helper.Locators.ext4Button("Save").findElement(metricWindow)); - waitForElement(Locator.linkWithText(metricProperties.get(ConfigureMetricsUIPage.TraceMetricProperties.metricName))); + clickAndWait(Locator.id("lk-trace-metric-save")); + String metricName = metricProperties.get(TraceMetricProperties.metricName); + if (metricName != null) + waitForElement(Locator.linkWithText(metricName)); } } @@ -331,37 +359,22 @@ public enum AnnotationMetricProperties public enum TraceMetricProperties { - metricName(null, false), - traceName("Use Trace", true), - yAxisLabel(null, false), - minTimeValue(null, false), - maxTimeValue(null, false), - traceValue(null, false), - timeValueOption(null, true, Ext4Helper.Locators.formItemWithInputNamed("timeValueOption")),; + metricName("lk-trace-metric-name", false), + traceName("lk-trace-use-trace", true), + yAxisLabel("lk-trace-ylabel", false), + minTimeValue("lk-trace-min-time", false), + maxTimeValue("lk-trace-max-time", false), + traceValue("lk-trace-value", false), + timeValueOption("lk-trace-time-option", true); - private final String formLabel; + private final String elementId; private final boolean isSelect; - private Locator.XPathLocator loc; - TraceMetricProperties(String formLabel, boolean isSelect) + TraceMetricProperties(String elementId, boolean isSelect) { - if (formLabel == null) - { - this.formLabel = null; - } - else - { - this.formLabel = formLabel + ":"; - } + this.elementId = elementId; this.isSelect = isSelect; } - - TraceMetricProperties(String formLabel, boolean isSelect, Locator.XPathLocator loc) - { - this(formLabel, isSelect); - this.loc = loc; - } - } } diff --git a/test/src/org/labkey/test/tests/panoramapremium/TargetedMSQCPremiumTest.java b/test/src/org/labkey/test/tests/panoramapremium/TargetedMSQCPremiumTest.java index 7f26c922d..29ddf438e 100644 --- a/test/src/org/labkey/test/tests/panoramapremium/TargetedMSQCPremiumTest.java +++ b/test/src/org/labkey/test/tests/panoramapremium/TargetedMSQCPremiumTest.java @@ -21,6 +21,8 @@ import org.junit.Test; import org.junit.experimental.categories.Category; import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.query.Filter; +import org.labkey.remoteapi.query.InsertRowsCommand; import org.labkey.remoteapi.query.SelectRowsCommand; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; @@ -37,6 +39,7 @@ import java.io.IOException; import java.util.Arrays; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -249,6 +252,11 @@ public void testTraceMetric() throws IOException, CommandException assertTrue("Should have precursor-scoped metrics in the second dropdown: " + metric2Options, metric2Options.contains(QCPlotsWebPart.MetricType.TRANSITION_AREA.toString())); assertFalse("Shouldn't have the same metric in the second dropdown: " + metric2Options, metric2Options.contains(QCPlotsWebPart.MetricType.RETENTION.toString())); + // use the pressure trace: its values stay well above the trace-value threshold below, + // whereas ColumnOven_FC_BridgeFlow sits around 7 and would never reach it + verifyTraceMetricModeSwitch(projectName, "ColumnPressure (channel 4)"); + verifyLegacyDualModeMetric(projectName, "ColumnPressure (channel 4)"); + log("Delete run and verify trace metric values are deleted"); clickTab("Runs"); TargetedMSRunsTable runsTable = new TargetedMSRunsTable(this); @@ -271,6 +279,11 @@ private int getTraceMetricValueRowCount() throws IOException, CommandException private void verifyQCPlot(String metricName, String tooltipValue) { log("Verify qc plots"); + Assertions.assertThat(getQCPlotHoverText(metricName)).as("Tooltip value").contains(tooltipValue); + } + + private String getQCPlotHoverText(String metricName) + { refresh(); PanoramaDashboard dashboard = new PanoramaDashboard(this); QCPlotsWebPart qcPlotsWebPart = dashboard.getQcPlotsWebPart(); @@ -280,8 +293,107 @@ private void verifyQCPlot(String metricName, String tooltipValue) assertFalse("Pressure trace plot is not present", pressurePlotSVGText.isEmpty()); assertTrue("Y axis label is not correct or present", pressurePlotSVGText.contains("psi")); qcPlotsWebPart.openExclusionBubble("2009-11-03 19:37:28"); - String pressureTraceHoverText = waitForElementToBeVisible(qcPlotsWebPart.getBubbleContent()).getText(); - Assertions.assertThat(pressureTraceHoverText).as("Tooltip value").contains(tooltipValue); + return waitForElementToBeVisible(qcPlotsWebPart.getBubbleContent()).getText(); + } + + /** + * A trace metric holds two mutually exclusive configurations - a time-value one and a trace-value one - and the + * server reads TimeValueOption first. If switching modes doesn't clear the columns for the mode being left behind, + * the row keeps both and the metric goes on computing the old one while the UI shows the new one. + */ + private void verifyTraceMetricModeSwitch(String projectName, String traceName) throws IOException, CommandException + { + final String metricName = "Mode Switch"; + // the pressure trace stays above this for the whole 5-7 minute window (the Min there is + // ~72.878), so the trace is guaranteed to reach it and the metric always has a value + final String traceValue = "50"; + + log("Add " + metricName + " in time-value mode"); + addNewTimeTraceMetrics(metricName, "Min", traceName, false); + assertTraceMetricConfig(projectName, metricName, "Min", 5.0, 7.0, null); + + goToProjectHome(projectName); + String timeModeHoverText = getQCPlotHoverText(metricName); + + log("Switch " + metricName + " to trace-value mode"); + Map traceMode = new LinkedHashMap<>(); + traceMode.put(ConfigureMetricsUIPage.TraceMetricProperties.metricName, metricName); + traceMode.put(ConfigureMetricsUIPage.TraceMetricProperties.traceValue, traceValue); + goToConfigureMetricsUI().editTraceMetric(metricName, traceMode); + + assertTraceMetricConfig(projectName, metricName, null, null, null, 50.0); + assertEquals("Mode the edit dialog reopened in", "traceValue", + goToConfigureMetricsUI().getTraceMetricMode(metricName)); + + goToProjectHome(projectName); + Assertions.assertThat(getQCPlotHoverText(metricName)) + .as("Plotted value after switching to trace-value mode") + .isNotEqualTo(timeModeHoverText); + + log("Switch " + metricName + " back to time-value mode"); + Map timeMode = new LinkedHashMap<>(); + timeMode.put(ConfigureMetricsUIPage.TraceMetricProperties.metricName, metricName); + timeMode.put(ConfigureMetricsUIPage.TraceMetricProperties.timeValueOption, "Min"); + timeMode.put(ConfigureMetricsUIPage.TraceMetricProperties.minTimeValue, "5"); + timeMode.put(ConfigureMetricsUIPage.TraceMetricProperties.maxTimeValue, "7"); + goToConfigureMetricsUI().editTraceMetric(metricName, timeMode); + + assertTraceMetricConfig(projectName, metricName, "Min", 5.0, 7.0, null); + assertEquals("Mode the edit dialog reopened in", "timeValue", + goToConfigureMetricsUI().getTraceMetricMode(metricName)); + + goToProjectHome(projectName); + } + + /** + * A legacy row can hold both configurations. The server reads TimeValueOption first, so the dialog must open + * such a row in time-value mode. + */ + private void verifyLegacyDualModeMetric(String projectName, String traceName) throws IOException, CommandException + { + final String metricName = "Legacy Dual Mode"; + + log("Insert " + metricName + " with both configurations set, as the pre-fix dialog left them"); + InsertRowsCommand insertCmd = new InsertRowsCommand("targetedms", "qcmetricconfiguration"); + Map row = new HashMap<>(); + row.put("Name", metricName); + row.put("QueryName", "QCTraceMetric"); // dummy text, same as the dialog inserts + row.put("PrecursorScoped", false); + row.put("TraceName", traceName); + row.put("YAxisLabel", "psi"); + row.put("TimeValueOption", "Min"); + row.put("MinTimeValue", 5.0); + row.put("MaxTimeValue", 7.0); + row.put("TraceValue", 50.0); + insertCmd.setRows(List.of(row)); + insertCmd.execute(createDefaultConnection(), "/" + projectName); + + assertEquals("Mode the edit dialog opened in for a metric with both configurations stored", "timeValue", + goToConfigureMetricsUI().getTraceMetricMode(metricName)); + + goToProjectHome(projectName); + } + + private void assertTraceMetricConfig(String projectName, String metricName, String timeValueOption, + Double minTimeValue, Double maxTimeValue, Double traceValue) + throws IOException, CommandException + { + SelectRowsCommand cmd = new SelectRowsCommand("targetedms", "qcmetricconfiguration"); + cmd.setColumns(Arrays.asList("Name", "TimeValueOption", "MinTimeValue", "MaxTimeValue", "TraceValue")); + cmd.addFilter(new Filter("Name", metricName)); + List> rows = cmd.execute(createDefaultConnection(), "/" + projectName).getRows(); + + assertEquals("Number of '" + metricName + "' metrics", 1, rows.size()); + Map row = rows.get(0); + assertEquals("TimeValueOption", timeValueOption, row.get("TimeValueOption")); + assertEquals("MinTimeValue", minTimeValue, asDouble(row.get("MinTimeValue"))); + assertEquals("MaxTimeValue", maxTimeValue, asDouble(row.get("MaxTimeValue"))); + assertEquals("TraceValue", traceValue, asDouble(row.get("TraceValue"))); + } + + private Double asDouble(Object value) + { + return value == null ? null : ((Number) value).doubleValue(); } private void addNewTimeTraceMetrics(String metricName, String timeValueOption, String traceName, boolean duplicateNameErrorExpected) diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSLibraryTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSLibraryTest.java index 8bf58e1d6..83bcd2f91 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSLibraryTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSLibraryTest.java @@ -22,7 +22,6 @@ import org.labkey.test.util.DataRegionTable; import org.labkey.test.util.LogMethod; import org.labkey.test.util.PipelineStatusTable; -import org.openqa.selenium.By; import org.openqa.selenium.WebElement; import java.util.ArrayList; @@ -222,13 +221,10 @@ private void verifyProteinDetailsPage() ensureComparisonPlots("CTCF"); log("Testing chart interactivity"); - WebElement height= getDriver().findElement(By.xpath("//input[contains(@id, 'chartHeight-inputEl')]")); - height.clear(); - height.sendKeys("200"); - WebElement width= getDriver().findElement(By.xpath("//input[contains(@id, 'chartWidth-inputEl')]")); - width.clear(); - width.sendKeys("500"); - clickButton("Update",0); + // ids from summaryChartsView.jsp; the chromatograms form has fields with the same names + setFormElement(Locator.id("sc-height"), "200"); + setFormElement(Locator.id("sc-width"), "500"); + click(Locator.id("sc-update")); ensureComparisonPlots("CTCF"); } diff --git a/webapp/TargetedMS/js/CalibrationCurve.js b/webapp/TargetedMS/js/CalibrationCurve.js index 20fb85ef4..44f045f0e 100644 --- a/webapp/TargetedMS/js/CalibrationCurve.js +++ b/webapp/TargetedMS/js/CalibrationCurve.js @@ -5,27 +5,29 @@ */ /** * Created by Marty on 3/16/2017. + * + * Plain JS/HTML implementation. Renders the calibration curve plot (LABKEY.vis) + * into the element identified by config.renderTo. */ - -Ext4.define('LABKEY.targetedms.CalibrationCurve', { - - extend: 'Ext.panel.Panel', - layout: 'fit', - border: false, - - selectedPointLayer: null, - plotHeight: 500, - minWidth: 800, - - colors: { - unknown: 'black', - standard: 'gray', - qc: 'green' - }, - - initComponent: function () { - Ext4.tip.QuickTipManager.init(); - this.callParent(); +if (!LABKEY.targetedms) { + LABKEY.targetedms = {}; +} + +(function() { + + function CalibrationCurve(config) { + this.renderTo = config.renderTo; + this.data = config.data; + + this.selectedPointLayer = null; + this.plotHeight = 500; + this.minWidth = 800; + this.plot = null; + this.colors = { + unknown: 'black', + standard: 'gray', + qc: 'green' + }; this.width = this.getPanelSize(); @@ -35,281 +37,286 @@ Ext4.define('LABKEY.targetedms.CalibrationCurve', { this.maxX = this.data.calibrationCurve.maxX || 0; // Ensure plot goes to max x axis for selected point calculations - var calcMaxX = this.getQuadraticIntersect(this, this.maxY); - if (calcMaxX > this.maxX) + const calcMaxX = this.getQuadraticIntersect(this, this.maxY); + if (calcMaxX > this.maxX) { this.maxX = calcMaxX; + } this.addCurvePoints(); this.refreshPlot(); - var me = this; - window.addEventListener("resize", function () { + const me = this; + window.addEventListener("resize", function() { // Issue 43532 - may have been loaded even if we don't have a curve to plot if (me.plot) { - me.setWidth(me.getPanelSize()); - me.plot.setWidth(me.getWidth()); + // Ext applied minWidth when the old resize handler round-tripped through + // setWidth()/getWidth(), so keep the plot from shrinking past it here + me.width = Math.max(me.getPanelSize(), me.minWidth); + me.plot.setWidth(me.width); me.plot.render(); // Plot re-renders so need to shrink dots to get back to initial state d3.selectAll('a.point path').transition().attr("stroke-width", 1); } }, false); - }, - - refreshPlot: function() { - // Clear out child elements - let children = document.getElementById(this.renderTo).childNodes; - for (let i = 0; i < children.length; ) { - if (children[i].localName === 'svg') { - children[i].parentNode.removeChild(children[i]); + } + + CalibrationCurve.prototype = { + + refreshPlot: function() { + // Clear out child elements + const children = document.getElementById(this.renderTo).childNodes; + for (let i = 0; i < children.length; ) { + if (children[i].localName === 'svg') { + children[i].parentNode.removeChild(children[i]); + } + else { + i++; + } } - else { - i++; + this.addPlot(); + }, + + // Add points for quadratic calculated concentration curve + addCurvePoints: function() { + const curvePts = 50; + let x, y; + const increment = (this.maxX - this.minX) / curvePts; + + this.data.curvePoints = []; + for (let pt = 0; pt <= curvePts; pt++) { + x = this.minX + (pt * increment); + y = this.data.calibrationCurve.quadraticCoefficient * (x * x) + this.data.calibrationCurve.slope * x + + this.data.calibrationCurve.intercept; + + this.data.curvePoints.push({x: x, y: y}); } - } - this.addPlot(); - }, - - // Add points for quadratic calculated concentration curve - addCurvePoints: function () { - var curvePts = 50; - var x, y; - var increment = (this.maxX - this.minX) / curvePts; - - this.data.curvePoints = []; - for (var pt = 0; pt <= curvePts; pt++) { - x = this.minX + (pt * increment); - y = this.data.calibrationCurve.quadraticCoefficient * (x * x) + this.data.calibrationCurve.slope * x - + this.data.calibrationCurve.intercept; - - this.data.curvePoints.push({x:x, y:y}); - } - }, + }, - getPanelSize: function () { - return window.innerWidth - 100; - }, + getPanelSize: function() { + return window.innerWidth - 100; + }, - // Given y, solve for x - getQuadraticIntersect: function (scope, y) { - var a = scope.data.calibrationCurve.quadraticCoefficient; - var b = scope.data.calibrationCurve.slope; - var c = scope.data.calibrationCurve.intercept; + // Given y, solve for x + getQuadraticIntersect: function(scope, y) { + const a = scope.data.calibrationCurve.quadraticCoefficient; + const b = scope.data.calibrationCurve.slope; + const c = scope.data.calibrationCurve.intercept; - var intersect; - if (a !== 0) { //Quadratic - intersect = ((-1 * b) + Math.sqrt((b * b) - (4 * a * (c - y)))) / (2 * a); - } - else { //Linear - intersect = (y - c) / b; - } - return intersect; - }, - - getPointToLineLayer: function (scope, point) { - var data = []; - data.push(point); - data.push({ - x: scope.getQuadraticIntersect(scope, point.y), - y: point.y, - type: point.type - }); - - data.push({ - x: scope.getQuadraticIntersect(scope, point.y), - y: scope.minY, - type: point.type - }); - - return new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({size: 3, opacity: 0, color: 'red'}), - aes: { - y: function (row) { - return row.y + let intersect; + if (a !== 0) { //Quadratic + intersect = ((-1 * b) + Math.sqrt((b * b) - (4 * a * (c - y)))) / (2 * a); + } + else { //Linear + intersect = (y - c) / b; + } + return intersect; + }, + + getPointToLineLayer: function(scope, point) { + const data = []; + data.push(point); + data.push({ + x: scope.getQuadraticIntersect(scope, point.y), + y: point.y, + type: point.type + }); + + data.push({ + x: scope.getQuadraticIntersect(scope, point.y), + y: scope.minY, + type: point.type + }); + + return new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({size: 3, opacity: 0, color: 'red'}), + aes: { + y: function(row) { + return row.y + }, + x: function(row) { + return row.x + } }, - x: function (row) { - return row.x - } - }, - data: data - }) - }, + data: data + }) + }, - addPlot: function () { - var me = this; + addPlot: function() { + const me = this; - if (this.data.calibrationCurve.errorMessage) { - document.getElementById(this.renderTo).innerText = this.data.calibrationCurve.errorMessage; - return; - } + if (this.data.calibrationCurve.errorMessage) { + document.getElementById(this.renderTo).innerText = this.data.calibrationCurve.errorMessage; + return; + } - // This is a dummy layer to be overwritten by the line layer when selecting a point - this.selectedPointLayer = new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({size: 3, opacity: 0}), - data: [], - aes: { - y: function (row) { - return row.y; - }, - x: function (row) { - return row.x; + // This is a dummy layer to be overwritten by the line layer when selecting a point + this.selectedPointLayer = new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({size: 3, opacity: 0}), + data: [], + aes: { + y: function(row) { + return row.y; + }, + x: function(row) { + return row.x; + } } + }); + + let units = ""; + if (this.data.calibrationCurve.units != null) { + units = "(" + this.data.calibrationCurve.units + ")"; } - }); - - var units = ""; - if (this.data.calibrationCurve.units != null) - units = "(" + this.data.calibrationCurve.units + ")"; - - this.plot = new LABKEY.vis.Plot({ - renderTo: this.renderTo, - rendererType: 'd3', - width: this.width, - height: this.plotHeight, - labels: { - main: {value: this.data.molecule.name}, - y: {value: 'Normalized Peak Areas'}, - x: {value: 'Analyte Concentration ' + units} - }, - layers: [ - this.selectedPointLayer, - new LABKEY.vis.Layer({ - data: this.data.curvePoints, - geom: new LABKEY.vis.Geom.Path({size: 3, opacity: .4}), - aes: { - y: 'y', - x: 'x' - } - }), - new LABKEY.vis.Layer({ - data: this.data.dataPoints, - geom: new LABKEY.vis.Geom.Point({size: 5, opacity: 0.75}), - aes: { - y: 'y', - x: 'x', - pointClickFn: function (event, data) { - var legend = me.getLegendDataInfo(me) - .concat(me.getLegendDataSlopeCalculations(me)) - .concat(me.getLegendDataPointCalculations(me, data)); - - me.plot.setLegend(legend); - - var lineLayer = me.getPointToLineLayer(me, data); - - me.plot.replaceLayer(me.selectedPointLayer, lineLayer); - me.selectedPointLayer = lineLayer; - me.plot.render(); - - // Shrink dots from previous clicks and grow clicked dot - d3.selectAll('a.point path').transition().attr("stroke-width", 1); - d3.select(event.srcElement).transition().attr("stroke-width", 8); - - // Transition in line layer visibility - d3.selectAll('svg g.layer path[stroke-opacity="0"').transition().attr('stroke-opacity', .5) - }, - hoverText: function (row) { - return 'Name: ' + row.name + '\nPeak Area: ' + me.formatLegendValue(row.y, true) + '\nConcentration: ' + me.formatLegendValue(row.x) + (row.excluded ? '\nExcluded from calibration' : ''); + + this.plot = new LABKEY.vis.Plot({ + renderTo: this.renderTo, + rendererType: 'd3', + width: this.width, + height: this.plotHeight, + labels: { + main: {value: this.data.molecule.name}, + y: {value: 'Normalized Peak Areas'}, + x: {value: 'Analyte Concentration ' + units} + }, + layers: [ + this.selectedPointLayer, + new LABKEY.vis.Layer({ + data: this.data.curvePoints, + geom: new LABKEY.vis.Geom.Path({size: 3, opacity: .4}), + aes: { + y: 'y', + x: 'x' + } + }), + new LABKEY.vis.Layer({ + data: this.data.dataPoints, + geom: new LABKEY.vis.Geom.Point({size: 5, opacity: 0.75}), + aes: { + y: 'y', + x: 'x', + pointClickFn: function(event, data) { + const legend = me.getLegendDataInfo(me) + .concat(me.getLegendDataSlopeCalculations(me)) + .concat(me.getLegendDataPointCalculations(me, data)); + + me.plot.setLegend(legend); + + const lineLayer = me.getPointToLineLayer(me, data); + + me.plot.replaceLayer(me.selectedPointLayer, lineLayer); + me.selectedPointLayer = lineLayer; + me.plot.render(); + + // Shrink dots from previous clicks and grow clicked dot + d3.selectAll('a.point path').transition().attr("stroke-width", 1); + d3.select(event.srcElement).transition().attr("stroke-width", 8); + + // Transition in line layer visibility + d3.selectAll('svg g.layer path[stroke-opacity="0"').transition().attr('stroke-opacity', .5) + }, + hoverText: function(row) { + return 'Name: ' + row.name + '\nPeak Area: ' + me.formatLegendValue(row.y) + '\nConcentration: ' + me.formatLegendValue(row.x) + (row.excluded ? '\nExcluded from calibration' : ''); + } } + }) + ], + aes: { + color: function(row) { + return row.type; + }, + shape: function(row) { + return row.excluded ? 'Excluded' : 'Included'; } - }) - ], - aes: { - color: function (row) { - return row.type; }, - shape: function (row) { - return row.excluded ? 'Excluded' : 'Included'; - } - }, - scales: { - color: { - scaleType: 'discrete', - scale: function (group) { - if (Ext4.isDefined(me.colors[group])) - return me.colors[group]; - - return 'blue'; + scales: { + color: { + scaleType: 'discrete', + scale: function(group) { + if (me.colors[group] !== undefined) + return me.colors[group]; + + return 'blue'; + }, }, - }, - y: { - scaleType: 'continuous', - trans: document.getElementById('calCurveYScale').value, - domain: [me.minY, me.maxY], - tickFormat: function (d) { - if (d < 1000 && d > 0.001) - return d; - return d.toExponential(); + y: { + scaleType: 'continuous', + trans: document.getElementById('calCurveYScale').value, + domain: [me.minY, me.maxY], + tickFormat: function(d) { + if (d < 1000 && d > 0.001) + return d; + return d.toExponential(); + } + }, + x: { + trans: document.getElementById('calCurveXScale').value } }, - x: { - trans: document.getElementById('calCurveXScale').value + legendData: this.getLegendDataInfo(me).concat(this.getLegendDataSlopeCalculations(me)), + legendNoWrap: true + }); + + this.plot.render(); + + LABKEY.targetedms.SVGChart.attachPlotExportIcons(this.renderTo, 'Calibration Curve: ' + this.data.molecule.name, 800, 0); + }, + + getLegendDataPointCalculations: function(scope, point) { + + const result = [ + {text: 'Selected Point', separator: true}, + {text: 'Replicate: ' + point.name, color: 'white'}, + {text: 'Peak Area: ' + scope.formatLegendValue(point.y), color: 'white'}, + {text: 'Concentration: ' + scope.formatLegendValue(point.x), color: 'white'}, + { + text: 'Calc. Concentration: ' + scope.formatLegendValue(scope.getQuadraticIntersect(scope, point.y)), + color: 'white' } - }, - legendData: this.getLegendDataInfo(me).concat(this.getLegendDataSlopeCalculations(me)), - legendNoWrap: true - }); - - this.plot.render(); - - LABKEY.targetedms.SVGChart.attachPlotExportIcons(this.renderTo, 'Calibration Curve: ' + this.data.molecule.name, 800, 0); - }, - - getLegendDataPointCalculations: function (scope, point) { - - var result = [ - {text: 'Selected Point', separator: true}, - {text: 'Replicate: ' + point.name, color: 'white'}, - {text: 'Peak Area: ' + scope.formatLegendValue(point.y, true), color: 'white'}, - {text: 'Concentration: ' + scope.formatLegendValue(point.x), color: 'white'}, - { - text: 'Calc. Concentration: ' + scope.formatLegendValue(scope.getQuadraticIntersect(scope, point.y)), - color: 'white' + ]; + if (point.excluded) { + result.push({text: 'Excluded from calibration', color: 'white'}); } - ]; - if (point.excluded) { - result.push({text: 'Excluded from calibration', color: 'white'}); - } - return result - }, - - getLegendDataSlopeCalculations: function (scope) { - var result = [ - {text: 'Calibration Curve', separator: true}, - {text: 'Regression Fit: ' + Ext4.util.Format.htmlEncode(this.data.calibrationCurve.regressionFit), color: 'white'}, - {text: 'Norm. Method: ' + Ext4.util.Format.htmlEncode(this.data.calibrationCurve.normalizationMethod), color: 'white'}, - {text: 'Regression Weighting: ' + Ext4.util.Format.htmlEncode(this.data.calibrationCurve.regressionWeighting), color: 'white'}, - {text: 'MS Level: ' + (this.data.msLevel > 0 ? this.data.msLevel : 'All'), color: 'white'}, - {text: '', separator: true}, - {text: 'Slope: ' + scope.formatLegendValue(this.data.calibrationCurve.slope, true), color: 'white'}, - {text: 'Intercept: ' + scope.formatLegendValue(this.data.calibrationCurve.intercept, true), color: 'white'} - ]; - if (this.data.calibrationCurve.quadraticCoefficient && this.data.calibrationCurve.quadraticCoefficient !== 0.0) { - result.push({text: 'Quadratic Coefficient: ' + scope.formatLegendValue(this.data.calibrationCurve.quadraticCoefficient), color: 'white'}); + return result + }, + + getLegendDataSlopeCalculations: function(scope) { + const result = [ + {text: 'Calibration Curve', separator: true}, + {text: 'Regression Fit: ' + LABKEY.Utils.encodeHtml(this.data.calibrationCurve.regressionFit), color: 'white'}, + {text: 'Norm. Method: ' + LABKEY.Utils.encodeHtml(this.data.calibrationCurve.normalizationMethod), color: 'white'}, + {text: 'Regression Weighting: ' + LABKEY.Utils.encodeHtml(this.data.calibrationCurve.regressionWeighting), color: 'white'}, + {text: 'MS Level: ' + (this.data.msLevel > 0 ? this.data.msLevel : 'All'), color: 'white'}, + {text: '', separator: true}, + {text: 'Slope: ' + scope.formatLegendValue(this.data.calibrationCurve.slope), color: 'white'}, + {text: 'Intercept: ' + scope.formatLegendValue(this.data.calibrationCurve.intercept), color: 'white'} + ]; + if (this.data.calibrationCurve.quadraticCoefficient && this.data.calibrationCurve.quadraticCoefficient !== 0.0) { + result.push({text: 'Quadratic Coefficient: ' + scope.formatLegendValue(this.data.calibrationCurve.quadraticCoefficient), color: 'white'}); + } + result.push({text: 'rSquared: ' + scope.formatLegendValue(this.data.calibrationCurve.rSquared), color: 'white'}); + result.push({text: '', separator: true}); + return result; + }, + + getLegendDataInfo: function(scope) { + return [ + {text: 'Standard', color: scope.colors['standard'], shape: LABKEY.vis.Scale.Shape()[0]}, + {text: 'QC', color: scope.colors['qc'], shape: LABKEY.vis.Scale.Shape()[0]}, + {text: 'Unknown', color: scope.colors['unknown'], shape: LABKEY.vis.Scale.Shape()[0]}, + {text: '', separator: true}, + {text: 'Excluded', color: scope.colors['standard'], shape: LABKEY.vis.Scale.Shape()[1]}, + {text: '', separator: true} + ]; + }, + + formatLegendValue: function(value) { + if (value == null) + return 'NaN'; + return Math.round(value * 100000) / 100000; } - result.push({text: 'rSquared: ' + scope.formatLegendValue(this.data.calibrationCurve.rSquared), color: 'white'}); - result.push({text: '', separator: true}); - return result; - }, - - getLegendDataInfo: function (scope) { - return [ - {text: 'Standard', color: scope.colors['standard'], shape:LABKEY.vis.Scale.Shape()[0]}, - {text: 'QC', color: scope.colors['qc'], shape:LABKEY.vis.Scale.Shape()[0]}, - {text: 'Unknown', color: scope.colors['unknown'], shape:LABKEY.vis.Scale.Shape()[0]}, - {text: '', separator: true}, - {text: 'Excluded', color: scope.colors['standard'], shape: LABKEY.vis.Scale.Shape()[1]}, - {text: '', separator: true} - ]; - }, - - formatLegendValue: function (value, exp) { - if (value == null) - return 'NaN'; - var rounded = Math.round(value * 100000) / 100000; - // Use scientific notation if the number is large or very small - if (exp && (rounded > 10000 || rounded < -10000 || (rounded > -0.00001 && rounded < 0.00001))) - rounded.toExponential(4) - return rounded; - } -}); \ No newline at end of file + }; + + LABKEY.targetedms.CalibrationCurve = CalibrationCurve; +})(); diff --git a/webapp/TargetedMS/js/ParetoPlotPanel.js b/webapp/TargetedMS/js/ParetoPlotPanel.js index d4f8473b4..612c00b9b 100644 --- a/webapp/TargetedMS/js/ParetoPlotPanel.js +++ b/webapp/TargetedMS/js/ParetoPlotPanel.js @@ -5,220 +5,445 @@ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 * * Created by binalpatel on 7/9/15. + * + * Plain JS/HTML implementation (no ExtJS). Renders one set of Pareto plots per guide set + * into the element identified by config.plotDivId. */ +if (!LABKEY.targetedms) { + LABKEY.targetedms = {}; +} -Ext4.define('LABKEY.targetedms.ParetoPlotPanel', { +(function() { - extend: 'LABKEY.targetedms.BaseQCPlotPanel', + const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - initComponent : function() - { - this.callParent(); + function ParetoPlotPanel(config) { + this.plotDivId = config.plotDivId; + this.metricPropArr = []; + this.plotWidth = null; + this._maskEl = null; - Ext4.get(this.plotDivId).mask("Loading..."); + this.mask('Loading...'); + const me = this; LABKEY.targetedms.QCMetricConfigLoader.getMetrics(this.initPlot, this, function() { - Ext4.get(this.plotDivId).unmask(); - Ext4.get(this.plotDivId).update('Failed to load'); + me.unmask(); + const el = me.getPlotDiv(); + if (el) { + el.innerHTML = 'Failed to load'; + } }); - }, + } - initPlot : function(metrics) { - this.metricPropArr = metrics; + ParetoPlotPanel.prototype = { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('targetedms', 'GetQCMetricOutliers.api'), - success: this.processResponse, - failure: LABKEY.Utils.getCallbackWrapper(this.failureHandler), - scope: this - }); - }, + getPlotDiv: function() { + return document.getElementById(this.plotDivId); + }, - processResponse: function(response) { - Ext4.get(this.plotDivId).unmask(); + mask: function(text) { + const el = this.getPlotDiv(); + if (!el) { + return; + } + this.unmask(); + const m = document.createElement('div'); + m.className = 'lk-pareto-loading'; + m.style.padding = '10px'; + m.textContent = text || 'Loading...'; + el.appendChild(m); + this._maskEl = m; + }, + + unmask: function() { + if (this._maskEl && this._maskEl.parentNode) { + this._maskEl.parentNode.removeChild(this._maskEl); + } + this._maskEl = null; + }, - var parsed = JSON.parse(response.responseText); + initPlot: function(metrics) { + this.metricPropArr = metrics; - if (!parsed.sampleFiles || Object.keys(parsed.sampleFiles).length === 0) { - Ext4.get(this.plotDivId).update('
No sample files loaded yet. Import some via Skyline, AutoQC, or the Data Pipeline tab here in Panorama.
'); - return; - } + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('targetedms', 'GetQCMetricOutliers.api'), + success: this.processResponse, + failure: LABKEY.Utils.getCallbackWrapper(this.failureHandler, this), + scope: this + }); + }, - var guideSets = parsed.guideSets; + processResponse: function(response) { + this.unmask(); - Ext4.each(guideSets, function(guideSet) { - guideSet.stats = { - CUSUMm: {count: 0, data: []}, - CUSUMv: {count: 0, data: []}, - mR: {count: 0, data: []}, - Value: {count: 0, data: []} - }; + const parsed = JSON.parse(response.responseText); + const el = this.getPlotDiv(); + + if (!parsed.sampleFiles || Object.keys(parsed.sampleFiles).length === 0) { + if (el) { + el.innerHTML = '
No sample files loaded yet. Import some via Skyline, AutoQC, or the Data Pipeline tab here in Panorama.
'; + } + return; + } - Ext4.iterate(guideSet.MetricCounts, function(metricName, data) { - this.addOutlierToCounts(guideSet, data, metricName,'CUSUMm', 'CUSUMm', true); - this.addOutlierToCounts(guideSet, data, metricName, 'CUSUMv', 'CUSUMv', true); - this.addOutlierToCounts(guideSet, data, metricName, 'mR', 'Moving Range', true); - this.addOutlierToCounts(guideSet, data, metricName, 'Value', 'Metric Value', true); - }, this); + const guideSets = parsed.guideSets; + const me = this; + + guideSets.forEach(function(guideSet) { + guideSet.stats = { + CUSUMm: {count: 0, data: []}, + CUSUMv: {count: 0, data: []}, + mR: {count: 0, data: []}, + Value: {count: 0, data: []} + }; + + Object.keys(guideSet.MetricCounts).forEach(function(metricName) { + const data = guideSet.MetricCounts[metricName]; + me.addOutlierToCounts(guideSet, data, metricName, 'CUSUMm', 'CUSUMm', true); + me.addOutlierToCounts(guideSet, data, metricName, 'CUSUMv', 'CUSUMv', true); + me.addOutlierToCounts(guideSet, data, metricName, 'mR', 'Moving Range', true); + me.addOutlierToCounts(guideSet, data, metricName, 'Value', 'Metric Value', true); + }); - Ext4.iterate(guideSet.stats, function(outlierType, data) { - var dataSet = data.data; + Object.keys(guideSet.stats).forEach(function(outlierType) { + const data = guideSet.stats[outlierType]; + const dataSet = data.data; - var totalCount = 0; - var maxOutliers = 0; + let totalCount = 0; + let maxOutliers = 0; - //find total count per guidesetID - for (var i = 0; i < dataSet.length; i++) { - totalCount += dataSet[i]['count']; + // find total count per guidesetID + for (let i = 0; i < dataSet.length; i++) { + totalCount += dataSet[i]['count']; - if(maxOutliers < dataSet[i]['count']) - { - maxOutliers = dataSet[i]['count']; + if (maxOutliers < dataSet[i]['count']) { + maxOutliers = dataSet[i]['count']; + } } - } - //sort by count in descending order - var sortedDataset = dataSet.sort(function(a, b) { - var order = b.count - a.count; - if (order !== 0) - return order; - return a.metricLabel.localeCompare(b.metricLabel); + // sort by count in descending order + const sortedDataset = dataSet.sort(function(a, b) { + const order = b.count - a.count; + if (order !== 0) { + return order; + } + return a.metricLabel.localeCompare(b.metricLabel); + }); + + // calculate cumulative percentage on sorted data + for (let j = 0; j < sortedDataset.length; j++) { + sortedDataset[j].percent = (j === 0 ? 0 : sortedDataset[j - 1].percent) + ((sortedDataset[j].count / totalCount) * 100); + } + data.maxOutliers = maxOutliers; }); + }); + + let guideSetCount = 1; + guideSets.forEach(function(guideSetData) { + const id = "paretoPlot-GuideSet-" + guideSetCount; + const dateFormat = LABKEY.extDefaultDateTimeFormat || 'Y-m-d H:i'; + const title = "Training Start: " + me.formatDate(new Date(guideSetData.TrainingStart), dateFormat) + + (guideSetData.ReferenceEnd ? " - Reference End: " + me.formatDate(new Date(guideSetData.ReferenceEnd), dateFormat) : " - Training End: " + me.formatDate(new Date(guideSetData.TrainingEnd), dateFormat)); + + const webpartTitleBase = "Guide Set " + guideSetCount + ' '; + const wp = 'pareto-plot-wp'; + const fileBase = "ParetoPlot-Guide Set " + guideSetCount; + + me.addEachParetoPlot(id, webpartTitleBase, "Metric Value", wp, title, fileBase, guideSetData.stats.Value.data, guideSetData.stats.Value.maxOutliers); + me.addEachParetoPlot(id + '_mR', webpartTitleBase, "Moving Range", wp, title, fileBase + '_mR', guideSetData.stats.mR.data, guideSetData.stats.mR.maxOutliers); + me.addEachParetoPlot(id + '_CUSUMm', webpartTitleBase, "Mean CUSUM", wp, title, fileBase + '_CUSUMm', guideSetData.stats.CUSUMm.data, guideSetData.stats.CUSUMm.maxOutliers); + me.addEachParetoPlot(id + '_CUSUMv', webpartTitleBase, "Variability CUSUM", wp, title, fileBase + '_CUSUMv', guideSetData.stats.CUSUMv.data, guideSetData.stats.CUSUMv.maxOutliers); + + guideSetCount++; + }); + }, + + addOutlierToCounts: function(guideSet, data, metricName, propertyName, plotTypeParamValue, isCusum) { + const count = data[propertyName]; + guideSet.stats[propertyName].count += count; + const newData = { + metricLabel: metricName, + count: count, + metricId: data.MetricId, + TrainingStart: guideSet.TrainingStart, + ReferenceEnd: guideSet.ReferenceEnd, + plotType: plotTypeParamValue + }; + if (isCusum) { + newData.CUSUMNegative = data[propertyName + 'N']; + newData.CUSUMPositive = data[propertyName + 'P']; + } - //calculate cumulative percentage on sorted data - for(var j = 0; j < sortedDataset.length; j++) { - sortedDataset[j].percent = (j == 0 ? 0 : sortedDataset[j-1].percent) + ((sortedDataset[j].count / totalCount) * 100); + guideSet.stats[propertyName].data.push(newData); + }, + + addEachParetoPlot: function(id, wpTitle, plotType, wp, plotTitle, fileName, plotData, yAxisMax) { + this.addPlotWebPartToPlotDiv(id, wpTitle, wp); + this.setPlotWidth(); + this.plotPareto(id, plotData, plotTitle, yAxisMax, plotType); + this.attachPlotExportIcons(id, id, 0, this.plotWidth - 30, 0); + }, + + plotPareto: function(id, data, title, yAxisMax, plotType) { + let tickValues; + if (yAxisMax < 10) { + tickValues = []; + for (let i = 0; i <= yAxisMax; i++) { + tickValues.push(i); } - data.maxOutliers = maxOutliers; - }, this) - }, this); - - - var guideSetCount = 1; - Ext4.each(guideSets, function(guideSetData) { - var id = "paretoPlot-GuideSet-"+guideSetCount; - const dateFormat = LABKEY.extDefaultDateTimeFormat || 'Y-m-d H:i'; - var title = "Training Start: " + Ext4.util.Format.date(new Date(guideSetData.TrainingStart), dateFormat) - + (guideSetData.ReferenceEnd ? " - Reference End: " + Ext4.util.Format.date(new Date(guideSetData.ReferenceEnd), dateFormat) : " - Training End: " + Ext4.util.Format.date(new Date(guideSetData.TrainingEnd), dateFormat)); - - var plotIdSuffix = '', plotType = "Metric Value", plotWp = 'pareto-plot-wp', plotData = guideSetData.stats.Value.data, plotMaxY = guideSetData.stats.Value.maxOutliers; - var webpartTitleBase = "Guide Set " + guideSetCount + ' '; - this.addEachParetoPlot(id, webpartTitleBase, plotType, plotWp, title, "ParetoPlot-Guide Set "+guideSetCount + plotIdSuffix, plotData, plotMaxY); - - plotIdSuffix = '_mR'; plotType = "Moving Range"; plotData = guideSetData.stats.mR.data; plotMaxY = guideSetData.stats.mR.maxOutliers; - this.addEachParetoPlot(id + plotIdSuffix, webpartTitleBase, plotType, plotWp, title, "ParetoPlot-Guide Set "+guideSetCount + plotIdSuffix, plotData, plotMaxY); - - plotIdSuffix = '_CUSUMm'; plotType = "Mean CUSUM"; plotData = guideSetData.stats.CUSUMm.data; plotMaxY = guideSetData.stats.CUSUMm.maxOutliers; - this.addEachParetoPlot(id + plotIdSuffix, webpartTitleBase, plotType, plotWp, title, "ParetoPlot-Guide Set "+guideSetCount + plotIdSuffix, plotData, plotMaxY); - - plotIdSuffix = '_CUSUMv'; plotType = "Variability CUSUM"; plotData = guideSetData.stats.CUSUMv.data; plotMaxY = guideSetData.stats.CUSUMv.maxOutliers; - this.addEachParetoPlot(id + plotIdSuffix, webpartTitleBase, plotType, plotWp, title, "ParetoPlot-Guide Set "+guideSetCount + plotIdSuffix, plotData, plotMaxY); - - guideSetCount++; - }, this); - }, - - addOutlierToCounts: function(guideSet, data, metricName, propertyName, plotTypeParamValue, isCusum) { - var count = data[propertyName]; - guideSet.stats[propertyName].count += count; - var newData = { - metricLabel: metricName, - count: count, - metricId: data.MetricId, - TrainingStart: guideSet.TrainingStart, - ReferenceEnd: guideSet.ReferenceEnd, - plotType: plotTypeParamValue - }; - if (isCusum) { - newData.CUSUMNegative = data[propertyName + 'N']; - newData.CUSUMPositive = data[propertyName + 'P']; - } - - guideSet.stats[propertyName].data.push(newData); - - }, - - addEachParetoPlot: function (id, wpTitle, plotType, wp, plotTitle, fileName, plotData, yAxisMax) - { - this.addPlotWebPartToPlotDiv(id, wpTitle, this.plotDivId, wp); - this.setPlotWidth(this.plotDivId); - this.plotPareto(id, plotData, plotTitle, yAxisMax, plotType); - this.attachPlotExportIcons(id, id, 0, this.plotWidth - 30, 0); - }, - - plotPareto: function(id, data, title, yAxisMax, plotType) - { - var tickValues; - if (yAxisMax < 10) { - tickValues = []; - for (var i = 0; i <= yAxisMax; i++) { - tickValues.push(i); } - } - var hoverFn = plotType.indexOf('CUSUM') > -1 ? this.plotBarHoverEvent : undefined; - var barChart = new LABKEY.vis.Plot({ - renderTo: id, - rendererType: 'd3', - width: this.plotWidth - 30, - height: 500, - data: Ext4.Array.clone(data), - labels: { - main: {value: "Pareto Plot - " + plotType}, - subtitle: {value: title, color: '#555555'}, - yLeft: {value: '# Outliers'}, - yRight: {value: 'Cumulative Percentage'} - }, - layers : [ - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.BarPlot({clickFn: this.plotBarClickEvent, hoverFn: hoverFn}) - }), - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({color: 'steelblue'}), - aes: { x: 'metricLabel', yRight: 'percent' } - }), - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Point({color: 'steelblue'}), - aes: { x: 'metricLabel', yRight: 'percent', hoverText: function(val){return val.percent.toPrecision(4) + "%"}} - }) - ], - aes: { - x: 'metricLabel', - y: 'count' - }, - scales : { - x : { - scaleType: 'discrete', - tickHoverText: function(val) { - return val; - } + const hoverFn = plotType.indexOf('CUSUM') > -1 ? this.plotBarHoverEvent : undefined; + const barChart = new LABKEY.vis.Plot({ + renderTo: id, + rendererType: 'd3', + width: this.plotWidth - 30, + height: 500, + data: data.slice(), + labels: { + main: {value: "Pareto Plot - " + plotType}, + subtitle: {value: title, color: '#555555'}, + yLeft: {value: '# Outliers'}, + yRight: {value: 'Cumulative Percentage'} + }, + layers: [ + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.BarPlot({clickFn: this.plotBarClickEvent, hoverFn: hoverFn}) + }), + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({color: 'steelblue'}), + aes: {x: 'metricLabel', yRight: 'percent'} + }), + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Point({color: 'steelblue'}), + aes: {x: 'metricLabel', yRight: 'percent', hoverText: function(val) {return val.percent.toPrecision(4) + "%"}} + }) + ], + aes: { + x: 'metricLabel', + y: 'count' }, - yLeft : { - domain: [0, (yAxisMax==0 ? 1 : yAxisMax)], - tickValues: tickValues + scales: { + x: { + scaleType: 'discrete', + tickHoverText: function(val) { + return val; + } + }, + yLeft: { + domain: [0, (yAxisMax === 0 ? 1 : yAxisMax)], + tickValues: tickValues + }, + yRight: { + domain: [0, 100] + } }, - yRight : { - domain: [0, 100] + margins: { + bottom: 75 } - }, - margins: { - bottom: 75 + }); + barChart.render(); + }, + + plotBarClickEvent: function(event, row) { + const params = {startDate: row.TrainingStart, metric: row.metricId, plotTypes: row.plotType}; + if (row.ReferenceEnd) { + params.endDate = row.ReferenceEnd; } - }); - barChart.render(); - }, - - plotBarClickEvent : function(event, row) { - var params = {startDate: row.TrainingStart, metric: row.metricId, plotTypes: row.plotType}; - if (row.ReferenceEnd) - { - params.endDate = row.ReferenceEnd; - } - window.location = LABKEY.ActionURL.buildURL('project', 'begin', null, params); - }, + window.location = LABKEY.ActionURL.buildURL('project', 'begin', null, params); + }, + + plotBarHoverEvent: function(row) { + const CUSUMN = row.CUSUMNegative ? row.CUSUMNegative : 0, CUSUMP = row.CUSUMPositive ? row.CUSUMPositive : 0; + return 'CUSUM-:' + ' ' + CUSUMN + '\nCUSUM+:' + ' ' + CUSUMP + '\nTotal: ' + row.count; + }, + + // ---- helpers previously inherited from the ExtJS BaseQCPlotPanel ---- + + getPlotWebPartHeader: function(wp, title) { + return '
' + + '' + + ' ' + + ' ' + + ' '; + }, + + addPlotWebPartToPlotDiv: function(id, title, wp) { + let html = this.getPlotWebPartHeader(wp, title); + html += '' + + ' ' + + ' ' + + '
' + + ' ' + LABKEY.Utils.encodeHtml(title) + '' + + '
' + + '
' + + '
'; + const div = this.getPlotDiv(); + if (div) { + div.insertAdjacentHTML('beforeend', html); + } + }, + + setPlotWidth: function() { + if (this.plotWidth == null) { + // set the width of the plot webparts based on the first labkey-wp-body element + this.plotWidth = 900; + const spacer = 33; + const wp = document.querySelector('.panel.panel-portal'); + if (wp && (wp.clientWidth - spacer) > this.plotWidth) { + this.plotWidth = wp.clientWidth - spacer; + } + + const div = this.getPlotDiv(); + if (div) { + div.style.width = this.plotWidth + 'px'; + } + } + }, + + attachPlotExportIcons: function(id, plotTitle, plotIndex, plotWidth, extraMargin) { + const me = this; + this.createExportIcon(id, 'fa-file-pdf-o', 'Export to PDF', 0, plotIndex, plotWidth, function() { + me.exportChartToImage(id, extraMargin, LABKEY.vis.SVGConverter.FORMAT_PDF, plotTitle); + }); + + this.createExportIcon(id, 'fa-file-image-o', 'Export to PNG', 1, plotIndex, plotWidth, function() { + me.exportChartToImage(id, extraMargin, LABKEY.vis.SVGConverter.FORMAT_PNG, plotTitle); + }); + }, + + createExportIcon: function(divId, iconCls, tooltip, indexFromLeft, plotIndex, plotWidth, callbackFn) { + const leftPositionPx = (indexFromLeft * 30) + 60, + exportIconDivId = divId + iconCls, + html = '
' + + '
'; + + const container = document.getElementById(divId); + if (container) { + container.insertAdjacentHTML('afterbegin', html); + const iconEl = document.getElementById(exportIconDivId); + if (iconEl) { + iconEl.addEventListener('click', callbackFn); + } + } + }, + + exportChartToImage: function(svgDivId, extraMargin, type, fileName) { + const svgStr = this.getExportSVGStr(svgDivId, extraMargin), + exportType = type || LABKEY.vis.SVGConverter.FORMAT_PDF; + LABKEY.vis.SVGConverter.convert(svgStr, exportType, fileName); + }, + + getExportSVGStr: function(svgDivId, extraWidth) { + const container = document.getElementById(svgDivId); + const targetSvg = container.querySelector('svg'); + const oldWidth = targetSvg.getBoundingClientRect().width; + // temporarily increase svg size to allow exporting of legends that's outside svg + if (extraWidth) { + targetSvg.setAttribute('width', oldWidth + extraWidth); + } + let svgStr = LABKEY.vis.SVGConverter.svgToStr(targetSvg); + if (extraWidth) { + targetSvg.setAttribute('width', oldWidth); + } + svgStr = svgStr.replace(/visibility="hidden"/g, 'visibility="visible"'); + return svgStr; + }, + + failureHandler: function(response) { + const plotDiv = this.getPlotDiv(); + if (plotDiv) { + this.unmask(); + if (!response) { + plotDiv.innerHTML = "Failure loading data"; + } + else if (response.message) { + plotDiv.innerHTML = "" + LABKEY.Utils.encodeHtml(response.message) + ""; + } + else { + plotDiv.innerHTML = "Error: " + LABKEY.Utils.encodeHtml(response.exception) + ""; + } + } + }, + + /** + * Minimal replacement for Ext4.util.Format.date, supporting the PHP/Ext-style tokens used by + * LABKEY.extDefaultDateTimeFormat (default 'Y-m-d H:i'). + */ + formatDate: function(date, format) { + if (!date || isNaN(date.getTime())) { + return ''; + } + const pad = function(n) { return (n < 10 ? '0' : '') + n; }; + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + const dow = date.getDay(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + let h12 = hours % 12; + if (h12 === 0) { + h12 = 12; + } + const pad3 = function(n) { return (n < 10 ? '00' : n < 100 ? '0' : '') + n; }; + + // Timezone abbreviation, derived the way Ext4 derived its 'T' token + const tzMatch = date.toString().match(/^.* (?:\((.*)\)|([A-Z]{1,5})(?:\s|$))/); + const tzOffset = date.getTimezoneOffset(); + const absOffset = Math.abs(tzOffset); + + // ISO-8601 week number: a week belongs to the year that its Thursday falls in + const thursday = new Date(year, month, day); + thursday.setDate(thursday.getDate() - ((dow + 6) % 7) + 3); + const firstThursday = new Date(thursday.getFullYear(), 0, 4); + firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3); + + const tokens = { + Y: '' + year, + y: ('' + year).slice(-2), + m: pad(month + 1), + n: '' + (month + 1), + F: MONTHS[month], + M: MONTHS[month].slice(0, 3), + d: pad(day), + j: '' + day, + l: DAYS[dow], + D: DAYS[dow].slice(0, 3), + N: '' + (dow === 0 ? 7 : dow), + w: '' + dow, + H: pad(hours), + G: '' + hours, + h: pad(h12), + g: '' + h12, + i: pad(minutes), + s: pad(seconds), + u: pad3(date.getMilliseconds()), + A: hours < 12 ? 'AM' : 'PM', + a: hours < 12 ? 'am' : 'pm', + // toExtDateFormat emits these from the Java z, Z, w and D patterns + T: tzMatch ? (tzMatch[1] || tzMatch[2]) : '', + O: (tzOffset > 0 ? '-' : '+') + pad(Math.floor(absOffset / 60)) + pad(absOffset % 60), + W: pad(1 + Math.round((thursday - firstThursday) / 604800000)), + z: '' + Math.round((new Date(year, month, day) - new Date(year, 0, 1)) / 86400000) + }; - plotBarHoverEvent : function(row) { - var CUSUMN = row.CUSUMNegative ? row.CUSUMNegative : 0, CUSUMP = row.CUSUMPositive ? row.CUSUMPositive : 0; - return 'CUSUM-:' + ' ' + CUSUMN + '\nCUSUM+:' + ' ' + CUSUMP + '\nTotal: ' + row.count; + let out = ''; + for (let i = 0; i < format.length; i++) { + const c = format[i]; + if (c === '\\' && i + 1 < format.length) { + out += format[++i]; + } + else if (Object.prototype.hasOwnProperty.call(tokens, c)) { + out += tokens[c]; + } + else { + out += c; + } + } + return out; + } + }; - } -}); \ No newline at end of file + LABKEY.targetedms.ParetoPlotPanel = ParetoPlotPanel; +})(); diff --git a/webapp/TargetedMS/js/QCMetricConfigLoader.js b/webapp/TargetedMS/js/QCMetricConfigLoader.js index 1dec39d2a..4da761d2d 100644 --- a/webapp/TargetedMS/js/QCMetricConfigLoader.js +++ b/webapp/TargetedMS/js/QCMetricConfigLoader.js @@ -30,7 +30,7 @@ if (!LABKEY.targetedms.QCMetricConfigLoader) { url: LABKEY.ActionURL.buildURL('targetedms', 'GetQCMetricConfigurations.api'), method: 'GET', success: function (response) { - const configs = Ext4.JSON.decode(response.responseText).configurations; + const configs = JSON.parse(response.responseText).configurations; this.initialQcMetrics = configs; for (const c of this.initialQcMetricsCallbacks) { if (c.callback) {