diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala index 9ceac44472a..2c9ea735d77 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala @@ -69,6 +69,28 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo @NotNull(message = "P-Value Column cannot be empty") var pvalueColumn: EncodableString = "" + @JsonProperty(required = false) + @JsonSchemaTitle("Title") + @JsonPropertyDescription("The figure title; leave empty for Volcano Plot") + var title: EncodableString = "" + + @JsonProperty(required = false) + @JsonSchemaTitle("X Label") + @JsonPropertyDescription("The label for the x axis; leave empty for the effect size column name") + var xLabel: EncodableString = "" + + @JsonProperty(required = false) + @JsonSchemaTitle("Y Label") + @JsonPropertyDescription("The label for the y axis; leave empty for -log10()") + var yLabel: EncodableString = "" + + @JsonProperty(required = false) + @JsonSchemaTitle("Color Bar Label") + @JsonPropertyDescription( + "The label for the color bar; leave empty for the effect size column name" + ) + var colorLabel: EncodableString = "" + override def operatorInfo: OperatorInfo = OperatorInfo.forVisualization( userFriendlyName = "Volcano Plot", @@ -84,6 +106,18 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo Map(operatorInfo.outputPorts.head.id -> outputSchema) } + /** The y column is named after the selected p-value column, so a figure drawn + * from an adjusted column says so instead of always reading "-log10(pvalue)". + */ + private def yColumn: EncodableString = s"-log10($pvalueColumn)" + + // An empty or blank label keeps the text the figure derives from the selected columns. + private def titleText: EncodableString = if (title.trim.nonEmpty) title else "Volcano Plot" + private def xAxisTitle: EncodableString = if (xLabel.trim.nonEmpty) xLabel else effectColumn + private def yAxisTitle: EncodableString = if (yLabel.trim.nonEmpty) yLabel else yColumn + private def colorBarTitle: EncodableString = + if (colorLabel.trim.nonEmpty) colorLabel else effectColumn + override def generatePythonCode(): String = { pyb""" |from pytexera import * @@ -112,16 +146,21 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo | yield {"html-content": self.render_error("No rows with valid p-values.")} | return | - | table["-log10(pvalue)"] = -np.log10(table[$pvalueColumn]) + | table[$yColumn] = -np.log10(table[$pvalueColumn]) | | fig = px.scatter( | table, | x=$effectColumn, - | y="-log10(pvalue)", + | y=$yColumn, | hover_name=table.columns[0], | color=$effectColumn, | color_continuous_scale="RdBu", - | title="Volcano Plot" + | title=$titleText + | ) + | fig.update_layout( + | xaxis_title=$xAxisTitle, + | yaxis_title=$yAxisTitle, + | coloraxis_colorbar_title_text=$colorBarTitle | ) | | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) @@ -131,9 +170,12 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo override def producesDataFrame(): Boolean = false + // The exported script draws the same figure as the engine, so it labels it the same + // way; the only difference is that the text is a Python literal rather than decoded. override def generateStandaloneCode(): String = { val pvalueLit = pyStringLiteral(pvalueColumn) val effectLit = pyStringLiteral(effectColumn) + val yLit = pyStringLiteral(yColumn) s"""import numpy as np | |def render_error(msg): @@ -151,15 +193,20 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo | with open(outputHtml, "w", encoding="utf-8") as output: | output.write(render_error("No rows with valid p-values.")) | else: - | table["-log10(pvalue)"] = -np.log10(table[$pvalueLit]) + | table[$yLit] = -np.log10(table[$pvalueLit]) | fig = px.scatter( | table, | x=$effectLit, - | y="-log10(pvalue)", + | y=$yLit, | hover_name=table.columns[0], | color=$effectLit, | color_continuous_scale="RdBu", - | title="Volcano Plot" + | title=${pyStringLiteral(titleText)} + | ) + | fig.update_layout( + | xaxis_title=${pyStringLiteral(xAxisTitle)}, + | yaxis_title=${pyStringLiteral(yAxisTitle)}, + | coloraxis_colorbar_title_text=${pyStringLiteral(colorBarTitle)} | ) | fig.write_json(outputJson) | fig.write_html(outputHtml) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDescSpec.scala index e85ed589efa..bd886cd0366 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDescSpec.scala @@ -20,10 +20,15 @@ package org.apache.texera.amber.operator.visualization.volcanoPlot import org.apache.texera.amber.core.tuple.AttributeType +import org.apache.texera.amber.operator.LogicalOp import org.apache.texera.amber.operator.metadata.OperatorGroupConstants +import org.apache.texera.amber.util.JSONUtils.objectMapper import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import java.nio.charset.StandardCharsets +import java.util.Base64 + class VolcanoPlotOpDescSpec extends AnyFlatSpec with Matchers { private def configured: VolcanoPlotOpDesc = { @@ -33,6 +38,14 @@ class VolcanoPlotOpDescSpec extends AnyFlatSpec with Matchers { op } + private def b64(s: String): String = + Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8)) + + // Every UI-provided string reaches the generated code as this runtime + // decode expression, never as a literal. + private def decoded(s: String): String = + s"self.decode_python_template('${b64(s)}')" + "VolcanoPlotOpDesc.operatorInfo" should "advertise the user-friendly name and Scientific group" in { val info = (new VolcanoPlotOpDesc).operatorInfo info.userFriendlyName shouldBe "Volcano Plot" @@ -63,11 +76,186 @@ class VolcanoPlotOpDescSpec extends AnyFlatSpec with Matchers { val code = configured.generatePythonCode() code should include("class ProcessTableOperator(UDFTableOperator)") code should include("plotly.express") - code should include("-log10(pvalue)") val decodeOccurrences = "decode_python_template".r.findAllIn(code).length decodeOccurrences should be >= 2 } + it should "name the y column and its axis after the selected p-value column" in { + // The y axis used to read "-log10(pvalue)" whatever column was chosen; a + // figure drawn from an adjusted column must say so. + val op = configured + op.pvalueColumn = "padj" + val code = op.generatePythonCode() + code should include(s"table[${decoded("-log10(padj)")}] = -np.log10(") + code should include(s"y=${decoded("-log10(padj)")}") + code should include(s"yaxis_title=${decoded("-log10(padj)")}") + code should not include "-log10(pvalue)" + code should not include b64("-log10(pvalue)") + } + + it should "fall back to the column-derived labels when no label is set" in { + val code = configured.generatePythonCode() + code should include(s"title=${decoded("Volcano Plot")}") + code should include(s"xaxis_title=${decoded("log2fc")}") + code should include(s"yaxis_title=${decoded("-log10(pvalue)")}") + code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}") + } + + it should "route every configured label through the decoder instead of the default" in { + // Sentinels with quotes, a backslash and non-ASCII text: as decode sites + // they cannot break the Python source, and the raw text must never + // appear in it. + val op = configured + op.title = "TITLE_SENT \"quoted\" 火山" + op.xLabel = "X_LBL_SENT log₂ fold change" + op.yLabel = "Y_LBL_SENT -log₁₀(BH-adjusted p)" + op.colorLabel = "COLOR_LBL_SENT \\ back\\slash" + val code = op.generatePythonCode() + code should include(s"title=${decoded(op.title)}") + code should include(s"xaxis_title=${decoded(op.xLabel)}") + code should include(s"yaxis_title=${decoded(op.yLabel)}") + code should include(s"coloraxis_colorbar_title_text=${decoded(op.colorLabel)}") + Seq(op.title, op.xLabel, op.yLabel, op.colorLabel).foreach { sentinel => + code should not include sentinel + } + // A set label replaces the default rather than being emitted next to it. + code should not include b64("Volcano Plot") + code should not include s"xaxis_title=${decoded("log2fc")}" + code should not include s"yaxis_title=${decoded("-log10(pvalue)")}" + code should not include s"coloraxis_colorbar_title_text=${decoded("log2fc")}" + // The y column itself keeps the derived name: a label is text on the axis, + // not a column rename. + code should include(s"y=${decoded("-log10(pvalue)")}") + } + + it should "treat a whitespace-only label as empty and keep the default" in { + val op = configured + op.title = " " + op.xLabel = "\t" + op.yLabel = " " + op.colorLabel = " \n " + val code = op.generatePythonCode() + code should include(s"title=${decoded("Volcano Plot")}") + code should include(s"xaxis_title=${decoded("log2fc")}") + code should include(s"yaxis_title=${decoded("-log10(pvalue)")}") + code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}") + Seq(op.title, op.xLabel, op.yLabel, op.colorLabel).foreach { blank => + code should not include decoded(blank) + } + } + + it should "treat each label independently so one set label leaves the others at their default" in { + val op = configured + op.yLabel = "Y_ONLY_SENT" + val code = op.generatePythonCode() + code should include(s"yaxis_title=${decoded("Y_ONLY_SENT")}") + code should include(s"title=${decoded("Volcano Plot")}") + code should include(s"xaxis_title=${decoded("log2fc")}") + code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}") + } + + it should "render a workflow saved before this change exactly as a default-constructed operator" in { + val saved = objectMapper + .readValue( + """{"operatorType":"VolcanoPlot","effectColumn":"log2fc","pvalueColumn":"pvalue"}""", + classOf[LogicalOp] + ) + .asInstanceOf[VolcanoPlotOpDesc] + val code = saved.generatePythonCode() + code shouldBe configured.generatePythonCode() + code should include(s"title=${decoded("Volcano Plot")}") + code should include(s"xaxis_title=${decoded("log2fc")}") + code should include(s"yaxis_title=${decoded("-log10(pvalue)")}") + code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}") + } + + "VolcanoPlotOpDesc.generateStandaloneCode" should "name the y column and its axis after the selected p-value column" in { + val op = configured + op.pvalueColumn = "padj" + val code = op.generateStandaloneCode() + code should include("""table["-log10(padj)"] = -np.log10(table["padj"])""") + code should include("""y="-log10(padj)",""") + code should include("""yaxis_title="-log10(padj)",""") + code should not include "-log10(pvalue)" + // The derived y column is the one literal built from a column name rather than + // taken from a field, so a quote in that name must survive the wrap and the escape. + op.pvalueColumn = "p\"adj" + val quoted = op.generateStandaloneCode() + quoted should include("table[\"-log10(p\\\"adj)\"] = -np.log10(table[\"p\\\"adj\"])") + quoted should include("yaxis_title=\"-log10(p\\\"adj)\",") + } + + it should "fall back to the column-derived labels when no label is set" in { + val code = configured.generateStandaloneCode() + code should include("""title="Volcano Plot"""") + code should include("""xaxis_title="log2fc",""") + code should include("""yaxis_title="-log10(pvalue)",""") + code should include("""coloraxis_colorbar_title_text="log2fc"""") + } + + it should "emit every configured label as an escaped Python literal" in { + // The export has no decoder, so the text itself is in the script: a quote or + // a backslash must come out escaped, or the label would end the literal early. + val op = configured + op.title = "TITLE_SENT \"quoted\" 火山" + op.xLabel = "X_LBL_SENT log₂ fold change" + op.yLabel = "Y_LBL_SENT -log₁₀(BH-adjusted p)" + op.colorLabel = "COLOR_LBL_SENT \\ back\\slash" + val code = op.generateStandaloneCode() + code should include("title=\"TITLE_SENT \\\"quoted\\\" 火山\"") + code should include("xaxis_title=\"X_LBL_SENT log₂ fold change\",") + code should include("yaxis_title=\"Y_LBL_SENT -log₁₀(BH-adjusted p)\",") + code should include("coloraxis_colorbar_title_text=\"COLOR_LBL_SENT \\\\ back\\\\slash\"") + // A set label replaces the default rather than being emitted next to it. + code should not include """title="Volcano Plot"""" + code should not include """xaxis_title="log2fc"""" + code should not include """yaxis_title="-log10(pvalue)"""" + code should not include """coloraxis_colorbar_title_text="log2fc"""" + // The y column itself keeps the derived name: a label is text on the axis, + // not a column rename. + code should include("""y="-log10(pvalue)",""") + } + + it should "treat a whitespace-only label as empty and keep the default" in { + val op = configured + op.title = " " + op.xLabel = "\t" + op.yLabel = " " + op.colorLabel = " \n " + op.generateStandaloneCode() shouldBe configured.generateStandaloneCode() + } + + it should "render a workflow saved before this change exactly as a default-constructed operator" in { + val saved = objectMapper + .readValue( + """{"operatorType":"VolcanoPlot","effectColumn":"log2fc","pvalueColumn":"pvalue"}""", + classOf[LogicalOp] + ) + .asInstanceOf[VolcanoPlotOpDesc] + saved.generateStandaloneCode() shouldBe configured.generateStandaloneCode() + } + + it should "label the exported figure as the engine labels its own" in { + // Both paths derive the same texts; one decodes them at run time and the + // other writes them as literals. A label that reaches only one of them would + // make the exported figure differ from the one shown in the workflow. + val op = configured + op.pvalueColumn = "padj" + op.title = "Treated vs control" + val engine = op.generatePythonCode() + val export = op.generateStandaloneCode() + Seq( + "title" -> "Treated vs control", + "xaxis_title" -> "log2fc", + "yaxis_title" -> "-log10(padj)", + "coloraxis_colorbar_title_text" -> "log2fc" + ).foreach { + case (key, text) => + engine should include(s"$key=${decoded(text)}") + export should include(s"""$key="$text"""") + } + } + it should "currently render code even when required fields are empty (no assert guard)" in { // Documents the present behavior: VolcanoPlotOpDesc does not assert on // its required fields inside `generatePythonCode`. An empty