From c9c9ae9bc4918b5a38e89868a40aa1800f3c5505 Mon Sep 17 00:00:00 2001 From: kary zheng Date: Wed, 2 Sep 2026 16:32:14 -0700 Subject: [PATCH 1/3] feat(visualization): export the tables, ranges and embedded documents Nine of them, and what they have in common is that the output is not a plot drawn from numbers: a table rendered as a figure, a range a reader moves, an image or an HTML document passed through, and the waterfall, which reads as a chart but is built by accumulating rows rather than plotting them. The waterfall plots every row and appends the total as a bar of its own, rather than consuming the last row to make one. Co-Authored-By: Claude Opus 5 (1M context) --- .../ImageViz/ImageVisualizerOpDesc.scala | 46 +++++- .../FigureFactoryTableOpDesc.scala | 47 +++++- .../visualization/htmlviz/HtmlVizOpDesc.scala | 17 +- .../nestedTable/NestedTableOpDesc.scala | 81 ++++++++- .../ParallelCoordinatesPlotOpDesc.scala | 43 ++++- .../rangeSlider/RangeSliderOpDesc.scala | 46 +++++- .../tablesChart/TablesPlotOpDesc.scala | 43 ++++- .../visualization/urlviz/UrlVizOpDesc.scala | 31 +++- .../waterfallChart/WaterfallChartOpDesc.scala | 56 ++++++- .../ImageViz/ImageVisualizerOpDescSpec.scala | 13 ++ .../WaterfallChartOpDescSpec.scala | 156 ++++++++++++++++++ 11 files changed, 549 insertions(+), 30 deletions(-) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala index 3f6fe276724..317112ea66b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala @@ -22,16 +22,19 @@ package org.apache.texera.amber.operator.visualization.ImageViz import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} -import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{ + PythonTemplateBuilderStringContext, + pyStringLiteral +} import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull -class ImageVisualizerOpDesc extends PythonOperatorDescriptor { +class ImageVisualizerOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonProperty(required = true) @JsonSchemaTitle("image content column") @@ -98,4 +101,41 @@ class ImageVisualizerOpDesc extends PythonOperatorDescriptor { finalCode.encode } + // Output is an HTML visualization, not a tabular DataFrame. + // The translator skips it in the leaf-DataFrame print block. + override def producesDataFrame(): Boolean = false + + override def generateStandaloneCode(): String = { + s"""import base64 + | + |LT = chr(60) + |GT = chr(62) + | + |def encode_image_to_html(binary_image_data): + | try: + | if isinstance(binary_image_data, str): + | encoded_image_str = binary_image_data + | else: + | encoded_image_data = base64.b64encode(binary_image_data) + | encoded_image_str = encoded_image_data.decode("utf-8") + | return ( + | f'{LT}img src="data:image;base64,{encoded_image_str}" alt="Image" ' + | f'style="max-width: 100vw; max-height: 90vh; width: auto; height: auto;"{GT}' + | ) + | except Exception: + | return ( + | f'{LT}h1{GT}Image is not available.{LT}/h1{GT}' + | f'{LT}p{GT}Reason: Binary input is not valid{LT}/p{GT}' + | ) + | + |all_images_html = f"{LT}div{GT}" + "".join( + | encode_image_to_html(binary_image_data) + | for binary_image_data in in1df[${pyStringLiteral(binaryContent)}] + |) + f"{LT}/div{GT}" + | + |with open("output.html", "w", encoding="utf-8") as output: + | output.write(all_images_html) + |print("Image visualizer saved to output.html")""".stripMargin + } + } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala index f9aa6bcd2b4..f919e9017b5 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala @@ -22,15 +22,18 @@ package org.apache.texera.amber.operator.visualization.figureFactoryTable import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} -import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{ + PythonTemplateBuilderStringContext, + pyStringLiteral +} import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.{DecimalMin, NotEmpty} -class FigureFactoryTableOpDesc extends PythonOperatorDescriptor { +class FigureFactoryTableOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonProperty(required = false) @JsonSchemaTitle("Font Size") @@ -44,7 +47,8 @@ class FigureFactoryTableOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("Font color of the Figure Factory Table") @JsonSchemaInject(json = """ { - "pattern": "^\\s*$|^\\s*#(?:\\s*[0-9a-fA-F]){3}(?:(?:\\s*[0-9a-fA-F]){3})?\\s*$|^\\s*(?:[rR]\\s*[gG]\\s*[bB]|[hH]\\s*[sS]\\s*[lL]|[hH]\\s*[sS]\\s*[vV])(?:\\s*[aA])?\\s*\\(\\s*(?:\\s*[0-9.])+(?:\\s*%)?(?:\\s*,(?:\\s*[0-9.])+(?:\\s*%)?){2,3}\\s*\\)\\s*$|^\\s*[vV]\\s*[aA]\\s*[rR]\\s*\\(\\s*-\\s*-[^)]*\\)\\s*$|^\\s*[a-zA-Z][a-zA-Z\\s]*$" + "pattern": "^\\s*$|^\\s*#(?:\\s*[0-9a-fA-F]){3}(?:(?:\\s*[0-9a-fA-F]){3})?\\s*$|^\\s*(?:[rR]\\s*[gG]\\s*[bB]|[hH]\\s*[sS]\\s*[lL]|[hH]\\s*[sS]\\s*[vV])(?:\\s*[aA])?\\s*\\(\\s*(?:\\s*[0-9.])+(?:\\s*%)?(?:\\s*,(?:\\s*[0-9.])+(?:\\s*%)?){2,3}\\s*\\)\\s*$|^\\s*[vV]\\s*[aA]\\s*[rR]\\s*\\(\\s*-\\s*-[^)]*\\)\\s*$|^\\s*[a-zA-Z][a-zA-Z\\s]*$", + "examples": ["red"] } """) var fontColor: EncodableString = "#000000" @@ -147,4 +151,39 @@ class FigureFactoryTableOpDesc extends PythonOperatorDescriptor { .add("html-content", AttributeType.STRING) Map(operatorInfo.outputPorts.head.id -> outputSchema) } + + override def producesDataFrame(): Boolean = false + + override def generateStandaloneCode(): String = { + val attributes = columns.map(c => pyStringLiteral(c.attributeName)).mkString(", ") + s"""import plotly.figure_factory as ff + | + |def render_error(error_msg): + | return '''

Figure factory table is not available.

+ |

Reason is: {}

+ | '''.format(error_msg) + | + |if in1df.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("input table is empty.")) + |else: + | table = in1df.dropna(subset=[$attributes]) + | if table.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("value column contains only non-positive numbers or nulls.")) + | else: + | filtered_table = table[[$attributes]] + | headers = filtered_table.columns.tolist() + | cell_values = [filtered_table[col].tolist() for col in headers] + | data = [headers] + list(map(list, zip(*cell_values))) + | fig = ff.create_table(data, height_constant=$rowHeight, font_colors=[${pyStringLiteral( + fontColor + )}]) + | for i in range(len(fig.layout.annotations)): + | fig.layout.annotations[i].font.size = $fontSize + | fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + | fig.write_json("output.json") + | fig.write_html("output.html") + | print("Figure factory table saved to output.html")""".stripMargin + } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/htmlviz/HtmlVizOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/htmlviz/HtmlVizOpDesc.scala index 4775a123d5a..cfb2f78e493 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/htmlviz/HtmlVizOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/htmlviz/HtmlVizOpDesc.scala @@ -25,9 +25,10 @@ import org.apache.texera.amber.core.executor.OpExecWithClassName import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity} import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc} -import org.apache.texera.amber.operator.LogicalOp -import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.operator.{LogicalOp, StandaloneCodeGenerator} +import org.apache.texera.amber.operator.metadata.annotations.{AutofillAttributeName, SampleColumn} import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral import org.apache.texera.amber.util.JSONUtils.objectMapper import javax.validation.constraints.NotNull @@ -36,10 +37,11 @@ import javax.validation.constraints.NotNull * HTML Visualization operator to render any given HTML code * This is the description of the operator */ -class HtmlVizOpDesc extends LogicalOp { +class HtmlVizOpDesc extends LogicalOp with StandaloneCodeGenerator { @JsonProperty(required = true) @JsonSchemaTitle("HTML content") @AutofillAttributeName + @SampleColumn("short_text") @NotNull(message = "HTML content cannot be empty") var htmlContentAttrName: String = "" @@ -74,4 +76,13 @@ class HtmlVizOpDesc extends LogicalOp { OperatorGroupConstants.VISUALIZATION_MEDIA_GROUP ) + // Output is a plain table (one "html-content" column), not a Plotly figure. + override def producesDataFrame(): Boolean = true + + // Mirrors HtmlVizOpExec: emit one row per input row whose single + // "html-content" column is the value of htmlContentAttrName, passed through + // unconverted (the exec does not coerce either). + override def generateStandaloneCode(): String = + s"""out1df = pd.DataFrame({"html-content": in1df[${pyStringLiteral(htmlContentAttrName)}]})""" + } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala index b32cd9740ac..573ba634f2b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala @@ -22,15 +22,16 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral import java.util import javax.validation.constraints.NotEmpty import scala.jdk.CollectionConverters.ListHasAsScala -class NestedTableOpDesc extends PythonOperatorDescriptor { +class NestedTableOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonPropertyDescription( "List of columns to include in the nested table chart and their subgroup" @@ -137,4 +138,80 @@ class NestedTableOpDesc extends PythonOperatorDescriptor { |""" finalcode.encode } + + override def producesDataFrame(): Boolean = false + + override def generateStandaloneCode(): String = { + val sortedColumns = includedColumns.asScala.sortBy(_.attributeGroup) + + val multiIndexTuples = sortedColumns + .map { config => + val name = + if (config.newName != null && config.newName.nonEmpty) config.newName + else config.originalName + s"""(${pyStringLiteral(config.attributeGroup)}, ${pyStringLiteral(name)})""" + } + .mkString(",\n ") + + val rowValues = sortedColumns + .map(config => s"""row[${pyStringLiteral(config.originalName)}]""") + .mkString(", ") + + s"""import pandas as pd + |import numpy as np + | + |def render_error(error_msg): + | return '''

Nested Table is not available.

+ |

Reason is: {}

+ | '''.format(error_msg) + | + |if in1df.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("input table is empty.")) + |else: + | columns = pd.MultiIndex.from_tuples([ + | $multiIndexTuples + | ]) + | + | data = [] + | for _, row in in1df.iterrows(): + | data.append([ + | $rowValues + | ]) + | + | df = pd.DataFrame(data, columns=columns) + | + | styles = [ + | {'selector': 'th', 'props': [('background-color', '#f2f2f2'), + | ('color', 'black'), + | ('font-weight', 'bold'), + | ('border', '1px solid #ddd'), + | ('padding', '8px'), + | ('text-align', 'center')]}, + | {'selector': 'td', 'props': [('border', '1px solid #ddd'), + | ('padding', '8px'), + | ('text-align', 'center')]}, + | {'selector': 'caption', 'props': [('caption-side', 'top'), + | ('font-size', '16pt'), + | ('font-weight', 'bold'), + | ('text-align', 'left'), + | ('padding', '10px')]}, + | {'selector': '.row_heading', 'props': [('text-align', 'left'), + | ('font-weight', 'normal')]}, + | {'selector': '.blank.level0', 'props': [('display', 'none')]} + | ] + | + | styled_table = ( + | df.style + | .set_table_styles(styles) + | .format(precision=2, na_rep="") + | .set_table_attributes('class="dataframe"') + | .hide(axis="index") + | ) + | + | html = styled_table.to_html() + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(html) + | print("Nested table saved to output.html")""".stripMargin + } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala index 4af91c332d9..184b00ba36c 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala @@ -23,7 +23,7 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.annotations.{ AutofillAttributeName, AutofillAttributeNameList @@ -31,7 +31,10 @@ import org.apache.texera.amber.operator.metadata.annotations.{ import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.pybuilder.PythonTemplateBuilder -import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{ + PythonTemplateBuilderStringContext, + pyStringLiteral +} import javax.validation.constraints.{NotNull, Size} @@ -50,7 +53,7 @@ import javax.validation.constraints.{NotNull, Size} } } """) -class ParallelCoordinatesPlotOpDesc extends PythonOperatorDescriptor { +class ParallelCoordinatesPlotOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonProperty(value = "dimensions", required = true) @JsonSchemaTitle("Dimensions") @@ -134,4 +137,38 @@ class ParallelCoordinatesPlotOpDesc extends PythonOperatorDescriptor { |""" finalcode.encode } + + override def producesDataFrame(): Boolean = false + + override def generateStandaloneCode(): String = { + val dimCols = dimensions.map(pyStringLiteral).mkString("[", ", ", "]") + val colorLit = pyStringLiteral(color) + val colorFilter = + if (color != null && color.nonEmpty) s""" & (in1df[$colorLit].notnull())""" else "" + val colorArg = + if (color != null && color.nonEmpty) s""", color=$colorLit""" else "" + + s"""def render_error(error_msg): + | return '''

Parallel coordinates plot is not available.

+ |

Reason is: {}

+ | '''.format(error_msg) + | + |if in1df.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("Input table is empty.")) + |else: + | table = in1df[in1df[$dimCols].notnull().all(axis=1)$colorFilter].copy() + | if table.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("No valid rows after filtering.")) + | else: + | fig = px.parallel_coordinates( + | table, + | dimensions=$dimCols$colorArg + | ) + | fig.write_json("output.json") + | fig.write_html("output.html") + | print("Parallel coordinates plot saved to output.html")""".stripMargin + } + } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala index 6b1425708d5..0e953e72d44 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala @@ -25,10 +25,11 @@ import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral import javax.validation.constraints.NotNull @@ -43,7 +44,7 @@ import javax.validation.constraints.NotNull } } """) -class RangeSliderOpDesc extends PythonOperatorDescriptor { +class RangeSliderOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonProperty(value = "Y-axis", required = true) @JsonSchemaTitle("Y-axis") @JsonPropertyDescription("The name of the column to represent y-axis") @@ -143,4 +144,45 @@ class RangeSliderOpDesc extends PythonOperatorDescriptor { finalcode.encode } + override def producesDataFrame(): Boolean = false + + override def generateStandaloneCode(): String = { + val functionType = duplicateType.getFunctionType + val xAxisLit = pyStringLiteral(xAxis) + val yAxisLit = pyStringLiteral(yAxis) + s"""def render_error(error_msg): + | return '''

RangeChart is not available.

+ |

Reason is: {}

+ | '''.format(error_msg) + | + |if in1df.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("input table is empty.")) + |elif $yAxisLit.strip() == "" or $xAxisLit.strip() == "": + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("Y-axis or X-axis is empty")) + |else: + | table = in1df + | table = table.dropna(subset=[$xAxisLit, $yAxisLit]) + | functionType = ${pyStringLiteral(functionType)} + | if functionType.lower() == "mean": + | table = table.groupby($xAxisLit)[$yAxisLit].mean().reset_index() + | elif functionType.lower() == "sum": + | table = table.groupby($xAxisLit)[$yAxisLit].sum().reset_index() + | fig = go.Figure() + | fig.add_trace(go.Scatter(x=table[$xAxisLit], y=table[$yAxisLit], mode="markers+lines")) + | fig.update_layout( + | xaxis_title=$xAxisLit, + | yaxis_title=$yAxisLit, + | xaxis=dict( + | rangeslider=dict( + | visible=True + | ) + | ) + | ) + | fig.write_json("output.json") + | fig.write_html("output.html") + | print("Range slider saved to output.html")""".stripMargin + } + } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala index 7290c057dd4..d6444dc8d37 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala @@ -23,12 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral import javax.validation.constraints.NotEmpty -class TablesPlotOpDesc extends PythonOperatorDescriptor { +class TablesPlotOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonPropertyDescription("List of columns to include in the table chart") @JsonProperty(value = "add attribute", required = true) @@ -114,4 +115,42 @@ class TablesPlotOpDesc extends PythonOperatorDescriptor { .add("html-content", AttributeType.STRING) Map(operatorInfo.outputPorts.head.id -> outputSchema) } + + override def producesDataFrame(): Boolean = false + + override def generateStandaloneCode(): String = { + assert(includedColumns.nonEmpty) + // Mirror getAttributes: a Python list literal of the selected column names. + val columnsList = + includedColumns.map(c => pyStringLiteral(c.attributeName)).mkString("[", ", ", "]") + // The two guards mirror generatePythonCode's, which reports both conditions + // rather than rendering a table with no rows in it. + s"""def render_error(error_msg): + | return '''

Tables Plot is not available.

+ |

Reason is: {}

+ | '''.format(error_msg) + | + |attributes = $columnsList + |if in1df.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("input table is empty.")) + |else: + | table = in1df.dropna(subset=attributes) + | if table.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("value column contains only non-positive numbers or nulls.")) + | else: + | filtered_table = table[attributes] + | headers = filtered_table.columns.tolist() + | cell_values = [filtered_table[col].tolist() for col in headers] + | + | fig = go.Figure(data=[go.Table( + | header=dict(values=headers), + | cells=dict(values=cell_values) + | )]) + | fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + | fig.write_json("output.json") + | fig.write_html("output.html") + | print("Tables plot saved to output.json")""".stripMargin + } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/urlviz/UrlVizOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/urlviz/UrlVizOpDesc.scala index 3add49a9e1a..2aa4742164c 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/urlviz/UrlVizOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/urlviz/UrlVizOpDesc.scala @@ -25,9 +25,10 @@ import org.apache.texera.amber.core.executor.OpExecWithClassName import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity} import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc} -import org.apache.texera.amber.operator.LogicalOp +import org.apache.texera.amber.operator.{LogicalOp, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral import org.apache.texera.amber.util.JSONUtils.objectMapper import javax.validation.constraints.NotNull @@ -45,7 +46,7 @@ import javax.validation.constraints.NotNull } } """) -class UrlVizOpDesc extends LogicalOp { +class UrlVizOpDesc extends LogicalOp with StandaloneCodeGenerator { @JsonProperty(required = true) @JsonSchemaTitle("URL content") @@ -84,4 +85,30 @@ class UrlVizOpDesc extends LogicalOp { OperatorGroupConstants.VISUALIZATION_MEDIA_GROUP ) + // Output is a plain table (one "html-content" column), not a Plotly figure. + override def producesDataFrame(): Boolean = true + + // Mirrors UrlVizOpExec: wrap each urlContentAttrName value in the exact same + // iframe HTML document and emit it as the "html-content" column. + override def generateStandaloneCode(): String = { + val urlLit = pyStringLiteral(urlContentAttrName) + s"""def _texera_urlviz_iframe(u): + | # "null", not Python's "None": the operator interpolates the field into a + | # Scala string, and the JVM renders a null that way. + | u = "null" if pd.isna(u) else u + | return ( + | '\\n' + | '\\n' + | '\\n' + | ' \\n' + | '\\n' + | '' + | ) + |out1df = pd.DataFrame({"html-content": in1df[$urlLit].apply(_texera_urlviz_iframe)})""".stripMargin + } + } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala index 87965a61d6e..9996534f4fc 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala @@ -25,10 +25,11 @@ import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral import javax.validation.constraints.NotNull @@ -41,7 +42,7 @@ import javax.validation.constraints.NotNull } } """) -class WaterfallChartOpDesc extends PythonOperatorDescriptor { +class WaterfallChartOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonProperty(value = "xColumn", required = true) @JsonSchemaTitle("X Axis Values") @@ -76,20 +77,24 @@ class WaterfallChartOpDesc extends PythonOperatorDescriptor { assert(xColumn.nonEmpty, "X Axis Values cannot be empty") assert(yColumn.nonEmpty, "Y Axis Values cannot be empty") pyb""" - | x_values = table[$xColumn] - | y_values = table[$yColumn] + | x_values = list(table[$xColumn]) + | y_values = list(table[$yColumn]) + | total = sum(y_values) | + | # every input row is a step; the total is an extra bar plotly accumulates, + | # so no row is consumed as the summary. A categorical axis keeps the bars in + | # input order, which is the order the running total is computed in. | fig = go.Figure(go.Waterfall( | name="Waterfall", orientation="v", - | measure=["relative"] * (len(y_values) - 1) + ["total"], - | x=x_values, - | y=y_values, + | measure=["relative"] * len(y_values) + ["total"], + | x=x_values + ["Total"], + | y=y_values + [0], | textposition="outside", - | text=[f"{v:+}" for v in y_values], + | text=[f"{v:+}" for v in y_values] + [f"{total}"], | connector={"line": {"color": "rgb(63, 63, 63)"}} | )) | - | fig.update_layout(showlegend=True, waterfallgap=0.3) + | fig.update_layout(showlegend=True, waterfallgap=0.3, xaxis_type="category") |""" } @@ -121,4 +126,37 @@ class WaterfallChartOpDesc extends PythonOperatorDescriptor { finalCode.encode } + override def producesDataFrame(): Boolean = false + + override def generateStandaloneCode(): String = { + s"""def render_error(error_msg) -> str: + | return '''

Waterfall chart is not available.

+ |

Reason is: {}

+ | '''.format(error_msg) + | + |if in1df.empty: + | with open("output.html", "w", encoding="utf-8") as output: + | output.write(render_error("input table is empty.")) + |else: + | table = in1df + | x_values = list(table[${pyStringLiteral(xColumn)}]) + | y_values = list(table[${pyStringLiteral(yColumn)}]) + | total = sum(y_values) + | + | fig = go.Figure(go.Waterfall( + | name="Waterfall", orientation="v", + | measure=["relative"] * len(y_values) + ["total"], + | x=x_values + ["Total"], + | y=y_values + [0], + | textposition="outside", + | text=[f"{v:+}" for v in y_values] + [f"{total}"], + | connector={"line": {"color": "rgb(63, 63, 63)"}} + | )) + | + | fig.update_layout(showlegend=True, waterfallgap=0.3, xaxis_type="category") + | fig.write_json("output.json") + | fig.write_html("output.html") + | print("Waterfall chart saved to output.html")""".stripMargin + } + } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDescSpec.scala index 2283610cd36..52cc96b240b 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDescSpec.scala @@ -103,4 +103,17 @@ class ImageVisualizerOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Mat code should include("encode_image_to_html") code should include("decode_python_template") } + + "ImageVisualizerOpDesc.generateStandaloneCode" should "avoid literal HTML tags that the UI code viewer can render away" in { + opDesc.binaryContent = "image_bytes" + val code = opDesc.generateStandaloneCode() + + code should include("LT = chr(60)") + code should include("GT = chr(62)") + code should include("encoded_image_str") + code should not include " Try(c.getConfig("python").getString("path")).toOption) + .map(_.trim) + .filter(_.nonEmpty) + } + + def isRunnable(exe: String): Boolean = { + val pTry = Try(new ProcessBuilder(exe, "--version").redirectErrorStream(true).start()) + pTry.toOption.exists { p => + val finished = p.waitFor(5, TimeUnit.SECONDS) + if (!finished) { p.destroyForcibly(); false } + else p.exitValue() == 0 + } + } + + (fromConfig.toList ++ List("python3", "python", "py")).distinct.find(isRunnable) + } + + private def canImportPandasAndPlotly(python: String): Boolean = { + val pTry = Try( + new ProcessBuilder(python, "-c", "import pandas, plotly").redirectErrorStream(true).start() + ) + pTry.toOption.exists { p => + val finished = p.waitFor(60, TimeUnit.SECONDS) + if (!finished) { p.destroyForcibly(); false } + else p.exitValue() == 0 + } + } + + // Driver executed by the runtime test below. It stubs only the pytexera import seam and + // swaps plotly's html renderer for its json one, so the trace the generated module hands + // plotly can be read back bar by bar. + private val runtimeDriverScript: String = + """import base64 + |import json + |import sys + |import types + |from typing import Iterator, Optional + | + |import pandas as pd + |import plotly.io + | + |plotly.io.to_html = lambda fig, **kwargs: fig.to_json() + | + |class UDFTableOperator: + | def decode_python_template(self, data): + | return base64.b64decode(data).decode("utf-8") + | + |stub = types.ModuleType("pytexera") + |stub.UDFTableOperator = UDFTableOperator + |stub.overrides = lambda fn: fn + |stub.Table = pd.DataFrame + |stub.TableLike = object + |stub.Iterator = Iterator + |stub.Optional = Optional + |sys.modules["pytexera"] = stub + | + |ns = {"__name__": "generated_waterfall_chart"} + |with open(sys.argv[1]) as f: + | exec(compile(f.read(), sys.argv[1], "exec"), ns) + |op = ns["ProcessTableOperator"]() + | + |cases = [ + | ("labels", pd.DataFrame({"x_col": ["a", "b", "c", "d"], "y_col": [1, 2, 3, 4]})), + | ("unsorted_numeric_x", pd.DataFrame({"x_col": [30, 10, 20], "y_col": [1, 2, 3]})), + |] + | + |for cid, df in cases: + | fig = json.loads(list(op.process_table(df, 0))[0]["html-content"]) + | trace = fig["data"][0] + | print("CASE %s measure=%s x=%s y=%s text=%s xaxis=%s" % ( + | cid, + | json.dumps(trace["measure"]), + | json.dumps(list(trace["x"])), + | json.dumps(list(trace["y"])), + | json.dumps(list(trace["text"])), + | fig["layout"]["xaxis"]["type"], + | )) + |""".stripMargin + + it should "plot the last row and draw the total over the whole column at runtime" in { + val python = resolvePythonExecutable().getOrElse( + cancel("No runnable python executable (udf.conf python.path, python3, python, py)") + ) + if (!canImportPandasAndPlotly(python)) { + cancel(s"'$python' cannot import pandas and plotly; skipping runtime verification") + } + + val moduleFile = Files.createTempFile("waterfall_chart_op_", ".py") + val driverFile = Files.createTempFile("waterfall_chart_driver_", ".py") + try { + Files.write(moduleFile, configured().generatePythonCode().getBytes(StandardCharsets.UTF_8)) + Files.write(driverFile, runtimeDriverScript.getBytes(StandardCharsets.UTF_8)) + + val process = new ProcessBuilder(python, driverFile.toString, moduleFile.toString) + .redirectErrorStream(true) + .start() + val finished = process.waitFor(120, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + fail("Runtime verification driver timed out after 120s") + } + val output = new String(process.getInputStream.readAllBytes(), StandardCharsets.UTF_8) + withClue(s"Driver output:\n$output\n") { + process.exitValue() shouldBe 0 + // Four rows of 1, 2, 3, 4 give four bars plus a total of 10. The old measure list + // spent the last row on the total, drawing three bars and a fourth at 6 labelled +4. + output should include( + """CASE labels measure=["relative", "relative", "relative", "relative", "total"] """ + + """x=["a", "b", "c", "d", "Total"] y=[1, 2, 3, 4, 0] """ + + """text=["+1", "+2", "+3", "+4", "10"] xaxis=category""" + ) + // An unsorted numeric x column keeps its input order, so the bar plotly accumulates + // into is the bar the running total was computed from. + output should include( + """CASE unsorted_numeric_x measure=["relative", "relative", "relative", "total"] """ + + """x=[30, 10, 20, "Total"] y=[1, 2, 3, 0] """ + + """text=["+1", "+2", "+3", "6"] xaxis=category""" + ) + } + } finally { + Try(Files.deleteIfExists(moduleFile)) + Try(Files.deleteIfExists(driverFile)) + () + } + } } From ab5ea74382268ad30edd9eec91e25f4b942853b4 Mon Sep 17 00:00:00 2001 From: kary zheng Date: Fri, 4 Sep 2026 12:12:52 -0700 Subject: [PATCH 2/3] feat(visualization): declare the plotly these charts draw with The translator no longer emits plotly into every script; it asks the operators in the plan what they need beyond pandas. Five of the nine here draw with plotly and mix in `PlotlyStandaloneCode`, which the hierarchy and graph charts introduce. The other four state nothing, which is the point of asking: an image, an HTML document, a nested table and a passed-through URL name none of plotly's modules, so a script built from them runs wherever pandas is installed. Co-Authored-By: Claude Opus 5 (1M context) --- .../figureFactoryTable/FigureFactoryTableOpDesc.scala | 5 +++-- .../ParallelCoordinatesPlotOpDesc.scala | 5 +++-- .../visualization/rangeSlider/RangeSliderOpDesc.scala | 5 +++-- .../visualization/tablesChart/TablesPlotOpDesc.scala | 5 +++-- .../visualization/waterfallChart/WaterfallChartOpDesc.scala | 5 +++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala index f919e9017b5..0f79a64abc4 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala @@ -28,12 +28,13 @@ import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{ } import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.visualization.PlotlyStandaloneCode import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.{DecimalMin, NotEmpty} -class FigureFactoryTableOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { +class FigureFactoryTableOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCode { @JsonProperty(required = false) @JsonSchemaTitle("Font Size") diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala index 184b00ba36c..dee456470bf 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/parallelCoordinatesPlot/ParallelCoordinatesPlotOpDesc.scala @@ -23,7 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.visualization.PlotlyStandaloneCode import org.apache.texera.amber.operator.metadata.annotations.{ AutofillAttributeName, AutofillAttributeNameList @@ -53,7 +54,7 @@ import javax.validation.constraints.{NotNull, Size} } } """) -class ParallelCoordinatesPlotOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { +class ParallelCoordinatesPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCode { @JsonProperty(value = "dimensions", required = true) @JsonSchemaTitle("Dimensions") diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala index 0e953e72d44..c5c9664f9e2 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala @@ -25,7 +25,8 @@ import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.visualization.PlotlyStandaloneCode import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @@ -44,7 +45,7 @@ import javax.validation.constraints.NotNull } } """) -class RangeSliderOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { +class RangeSliderOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCode { @JsonProperty(value = "Y-axis", required = true) @JsonSchemaTitle("Y-axis") @JsonPropertyDescription("The name of the column to represent y-axis") diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala index d6444dc8d37..2013562609d 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala @@ -23,13 +23,14 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.visualization.PlotlyStandaloneCode import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral import javax.validation.constraints.NotEmpty -class TablesPlotOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { +class TablesPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCode { @JsonPropertyDescription("List of columns to include in the table chart") @JsonProperty(value = "add attribute", required = true) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala index 9996534f4fc..9f7e766c306 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala @@ -25,7 +25,8 @@ import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.visualization.PlotlyStandaloneCode import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @@ -42,7 +43,7 @@ import javax.validation.constraints.NotNull } } """) -class WaterfallChartOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { +class WaterfallChartOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCode { @JsonProperty(value = "xColumn", required = true) @JsonSchemaTitle("X Axis Values") From 52f04ef547c77f815c8721a8aa7f5889acfad48c Mon Sep 17 00:00:00 2001 From: kary zheng Date: Fri, 4 Sep 2026 12:40:41 -0700 Subject: [PATCH 3/3] feat(visualization): leave the plotly mixin to the charts that use it Figure Factory Table imports `plotly.figure_factory` inside its own generated code, and uses none of the three modules the mixin declares. Mixing it in would put an import in the script that the script never reads. The mixin states what a script has to import at module scope. An operator that imports what it needs inside its own code has nothing to add there. Co-Authored-By: Claude Opus 5 (1M context) --- .../figureFactoryTable/FigureFactoryTableOpDesc.scala | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala index 0f79a64abc4..f919e9017b5 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala @@ -28,13 +28,12 @@ import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{ } import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.PortIdentity -import org.apache.texera.amber.operator.PythonOperatorDescriptor -import org.apache.texera.amber.operator.visualization.PlotlyStandaloneCode +import org.apache.texera.amber.operator.{PythonOperatorDescriptor, StandaloneCodeGenerator} import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.{DecimalMin, NotEmpty} -class FigureFactoryTableOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCode { +class FigureFactoryTableOpDesc extends PythonOperatorDescriptor with StandaloneCodeGenerator { @JsonProperty(required = false) @JsonSchemaTitle("Font Size")