This is an automated email from the ASF dual-hosted git repository.

linxinyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new d868b85225 feat: add Radar Plot Visualization Operator (#3500)
d868b85225 is described below

commit d868b85225fd479b9d453a047decb3da1cb59619
Author: Madison Lin <[email protected]>
AuthorDate: Fri Apr 17 00:02:02 2026 -0700

    feat: add Radar Plot Visualization Operator (#3500)
    
    This PR introduces a new visualization operator for a Radar Plot (also
    known as a spider plot). Radar plots are used to visualize multivariate
    data, allowing for comparison of different entities across the multiple
    variables. Typically, they are used to determine strengths and
    weaknesses of entities compared to one another (e.g. how our product
    compares to a competitor’s). They are also effective in showing outliers
    and commonalities across the entities. This Radar Plot visualization
    operator allows the user to select which numeric columns will be used as
    the radar axes, and optionally select a column whose value will be used
    to determine the name or color for the corresponding radar trace (the
    visual representation of that entity in the plot). Users can also toggle
    other customization features, such as to normalize the values for each
    radar axis, in order to prevent a representation that skews towards axes
    with higher values.
    
    Changed files:
    - Created RadarPlot.png in core/gui/src/assets/operator_images
    - Created radarPlot/RadarPlotOpDesc.scala in
    
core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/visualization
    - Created radarPlot/RadarPlotLinePattern.java in
    
core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/visualization
    - Updated LogicalOp.scala in
    core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator
    
    Property Selection:
    
    
![diabetes_radar_plot_properties](https://github.com/user-attachments/assets/63810a9f-9613-49a1-9ae0-a508a4cbc151)
    
    Normalized Result:
    
    
![diabetes_radar_plot_normalized](https://github.com/user-attachments/assets/e63547bd-0968-4557-9844-c2f9debb27ff)
    
    Non-Normalized Result:
    
    
![diabetes_radar_plot_non_normalized](https://github.com/user-attachments/assets/e0e180e6-85c5-4dca-9b2f-ef6572162984)
    
    Additional Example:
    
    
![product_radar_plot](https://github.com/user-attachments/assets/cc77432c-7c1d-421d-b09e-dbfa6bfb9df1)
    
    Testing Datasets:
    
    
[diabetes_mini.csv](https://github.com/user-attachments/files/20912990/diabetes_mini.csv)
    
    
[diabetes_mini_missing_column.csv](https://github.com/user-attachments/files/20912991/diabetes_mini_missing_column.csv)
    
    
[diabetes_mini_missing_values.csv](https://github.com/user-attachments/files/20912994/diabetes_mini_missing_values.csv)
    
    
[product_data.csv](https://github.com/user-attachments/files/20912995/product_data.csv)
    
    ---------
    
    Co-authored-by: Xinyuan Lin <[email protected]>
---
 .../apache/texera/amber/operator/LogicalOp.scala   |   2 +
 .../radarChart/RadarChartOpDesc.scala              |   3 +-
 .../radarPlot/RadarPlotLinePattern.java            |  37 ++++
 .../visualization/radarPlot/RadarPlotOpDesc.scala  | 230 +++++++++++++++++++++
 frontend/src/assets/operator_images/RadarPlot.png  | Bin 0 -> 48432 bytes
 5 files changed, 270 insertions(+), 2 deletions(-)

diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala
index 2d2bd24e8a..7671555d0e 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala
@@ -124,6 +124,7 @@ import 
org.apache.texera.amber.operator.visualization.nestedTable.NestedTableOpD
 import 
org.apache.texera.amber.operator.visualization.networkGraph.NetworkGraphOpDesc
 import org.apache.texera.amber.operator.visualization.pieChart.PieChartOpDesc
 import 
org.apache.texera.amber.operator.visualization.quiverPlot.QuiverPlotOpDesc
+import org.apache.texera.amber.operator.visualization.radarPlot.RadarPlotOpDesc
 import 
org.apache.texera.amber.operator.visualization.radarChart.RadarChartOpDesc
 import 
org.apache.texera.amber.operator.visualization.rangeSlider.RangeSliderOpDesc
 import 
org.apache.texera.amber.operator.visualization.sankeyDiagram.SankeyDiagramOpDesc
@@ -196,6 +197,7 @@ trait StateTransferFunc
     new Type(value = classOf[RangeSliderOpDesc], name = "RangeSlider"),
     new Type(value = classOf[PieChartOpDesc], name = "PieChart"),
     new Type(value = classOf[QuiverPlotOpDesc], name = "QuiverPlot"),
+    new Type(value = classOf[RadarPlotOpDesc], name = "RadarPlot"),
     new Type(value = classOf[RadarChartOpDesc], name = "RadarChart"),
     new Type(value = classOf[WordCloudOpDesc], name = "WordCloud"),
     new Type(value = classOf[HtmlVizOpDesc], name = "HTMLVisualizer"),
diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarChart/RadarChartOpDesc.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarChart/RadarChartOpDesc.scala
index 8454729657..31f86d302a 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarChart/RadarChartOpDesc.scala
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarChart/RadarChartOpDesc.scala
@@ -22,8 +22,7 @@ package 
org.apache.texera.amber.operator.visualization.radarChart
 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.OutputPort.OutputMode
-import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, 
PortIdentity}
+import org.apache.texera.amber.core.workflow.PortIdentity
 import org.apache.texera.amber.operator.PythonOperatorDescriptor
 import org.apache.texera.amber.operator.metadata.annotations.{
   AutofillAttributeName,
diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotLinePattern.java
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotLinePattern.java
new file mode 100644
index 0000000000..a37838f9d0
--- /dev/null
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotLinePattern.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.texera.amber.operator.visualization.radarPlot;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+public enum RadarPlotLinePattern {
+    SOLID("solid"),
+    DASH("dash"),
+    DOT("dot");
+    private final String linePattern;
+
+    RadarPlotLinePattern(String linePattern) {
+        this.linePattern = linePattern;
+    }
+
+    @JsonValue
+    public String getLinePattern() {
+        return this.linePattern;
+    }
+}
diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
new file mode 100644
index 0000000000..0659c3ceeb
--- /dev/null
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.amber.operator.visualization.radarPlot
+
+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.operator.metadata.annotations.{
+  AutofillAttributeName,
+  AutofillAttributeNameList
+}
+import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, 
OperatorInfo}
+import org.apache.texera.amber.operator.PythonOperatorDescriptor
+import org.apache.texera.amber.core.workflow.PortIdentity
+import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString
+import org.apache.texera.amber.pybuilder.PythonTemplateBuilder
+import 
org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext
+
+@JsonSchemaInject(json = """
+{
+  "attributeTypeRules": {
+    "selectedAttributes": {
+      "enum": ["integer", "long", "double"]
+    }
+  }
+}
+""")
+class RadarPlotOpDesc extends PythonOperatorDescriptor {
+  @JsonProperty(value = "selectedAttributes", required = true)
+  @JsonSchemaTitle("Axes")
+  @JsonPropertyDescription("Numeric columns to use as radar axes")
+  @AutofillAttributeNameList
+  var selectedAttributes: List[EncodableString] = _
+
+  @JsonProperty(value = "traceNameAttribute", defaultValue = "No Selection", 
required = false)
+  @JsonSchemaTitle("Trace Name Column")
+  @JsonPropertyDescription("Optional - Select a column to use for naming each 
radar trace")
+  @AutofillAttributeName
+  var traceNameAttribute: EncodableString = ""
+
+  @JsonProperty(
+    value = "traceColorAttribute",
+    defaultValue = "No Selection",
+    required = false
+  )
+  @JsonSchemaTitle("Trace Color Column")
+  @JsonPropertyDescription(
+    "Optional - Select a column to use for coloring each radar trace (note: if 
there are too many traces with distinct coloring values, colors may repeat)"
+  )
+  @AutofillAttributeName
+  var traceColorAttribute: EncodableString = ""
+
+  @JsonProperty(value = "linePattern", defaultValue = "solid", required = true)
+  @JsonPropertyDescription("Pattern of the lines connecting points on the 
radar plot")
+  var linePattern: RadarPlotLinePattern = _
+
+  @JsonProperty(value = "maxNormalize", defaultValue = "true", required = true)
+  @JsonSchemaTitle("Max Normalize")
+  @JsonPropertyDescription(
+    "Normalize radar plot values by scaling them relative to the maximum value 
on their respective axes"
+  )
+  var maxNormalize: Boolean = true
+
+  @JsonProperty(value = "fillTrace", defaultValue = "true", required = true)
+  @JsonSchemaTitle("Fill Trace")
+  @JsonPropertyDescription("Fill the area within each radar trace")
+  var fillTrace: Boolean = true
+
+  @JsonProperty(value = "showMarkers", defaultValue = "true", required = true)
+  @JsonSchemaTitle("Show Point Markers")
+  @JsonPropertyDescription("Display point markers on the radar plot")
+  var showMarkers: Boolean = true
+
+  @JsonProperty(value = "showLegend", defaultValue = "true", required = false)
+  @JsonSchemaTitle("Show Legend")
+  @JsonPropertyDescription(
+    "Display the legend (note: without the legend, you are unable to 
selectively hide or show traces in the plot)"
+  )
+  var showLegend: Boolean = true
+
+  override def getOutputSchemas(
+      inputSchemas: Map[PortIdentity, Schema]
+  ): Map[PortIdentity, Schema] = {
+    val outputSchema = Schema()
+      .add("html-content", AttributeType.STRING)
+    Map(operatorInfo.outputPorts.head.id -> outputSchema)
+  }
+
+  override def operatorInfo: OperatorInfo =
+    OperatorInfo.forVisualization(
+      "Radar Plot",
+      "View the result in a radar plot.",
+      OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP
+    )
+
+  private def toPythonBool(value: Boolean): String = if (value) "True" else 
"False"
+
+  private def optionalColumnExpr(column: EncodableString): 
PythonTemplateBuilder =
+    Option(column).filterNot(col => col.isEmpty || col == "No Selection") 
match {
+      case Some(col) => pyb"$col"
+      case None      => pyb"None"
+    }
+
+  def generateRadarPlotCode(): PythonTemplateBuilder = {
+    val attributes = Option(selectedAttributes).getOrElse(Nil)
+    val attrList = attributes.map(attr => pyb"$attr").mkString(", ")
+    val traceNameCol = optionalColumnExpr(traceNameAttribute)
+    val traceColorCol = optionalColumnExpr(traceColorAttribute)
+
+    pyb"""
+       |        categories = [$attrList]
+       |        if not categories:
+       |            yield {'html-content': self.render_error("No columns 
selected as axes.")}
+       |            return
+       |
+       |        trace_name_col = $traceNameCol
+       |        trace_color_col = $traceColorCol
+       |        line_pattern = "${linePattern.getLinePattern}"
+       |        max_normalize = ${toPythonBool(maxNormalize)}
+       |        fill_trace = ${toPythonBool(fillTrace)}
+       |        show_markers = ${toPythonBool(showMarkers)}
+       |        show_legend = ${toPythonBool(showLegend)}
+       |
+       |        selected_table_df = table[categories].astype(float)
+       |        selected_table = selected_table_df.values
+       |
+       |        trace_names = (
+       |            table[trace_name_col].values if trace_name_col
+       |            else np.full(len(table), "", dtype=object)
+       |        )
+       |
+       |        trace_colors = [None] * len(table)
+       |        if trace_color_col:
+       |            unique_vals = table[trace_color_col].unique()
+       |            color_map = {val: px.colors.qualitative.Plotly[idx % 
len(px.colors.qualitative.Plotly)]
+       |                         for idx, val in enumerate(unique_vals)}
+       |            nan_color = '#000000'
+       |            trace_colors = 
table[trace_color_col].map(color_map).fillna(nan_color).values
+       |
+       |        hover_texts = []
+       |        for idx, row in enumerate(selected_table):
+       |            name_prefix = str(trace_names[idx]) + "<br>" if 
trace_names[idx] else ""
+       |            row_hover_texts = []
+       |            for attr, value in zip(categories, row):
+       |                row_hover_texts.append(name_prefix + attr + ": " + 
str(value))
+       |            hover_texts.append(row_hover_texts)
+       |
+       |        if max_normalize:
+       |            max_vals = selected_table_df.max().values
+       |            max_vals[max_vals == 0] = 1
+       |            selected_table = selected_table / max_vals
+       |
+       |        selected_table = np.nan_to_num(selected_table)
+       |
+       |        fig = go.Figure()
+       |
+       |        for idx, row in enumerate(selected_table):
+       |            # To connect ensure all points in the radar trace are 
connected
+       |            closed_row = row.tolist() + [row[0]]
+       |            closed_categories = categories + [categories[0]]
+       |            closed_hover_texts = hover_texts[idx] + 
[hover_texts[idx][0]]
+       |
+       |            fig.add_trace(go.Scatterpolar(
+       |                r=closed_row,
+       |                theta=closed_categories,
+       |                fill='toself' if fill_trace else 'none',
+       |                name=str(trace_names[idx]) if trace_names[idx] else "",
+       |                text=closed_hover_texts,
+       |                hoverinfo="text",
+       |                mode="lines+markers" if show_markers else "lines",
+       |                line=dict(dash=line_pattern, color=trace_colors[idx] 
if trace_colors[idx] else None),
+       |                marker=dict(color=trace_colors[idx]) if 
trace_colors[idx] else {}
+       |            ))
+       |
+       |        fig.update_layout(
+       |            polar=dict(radialaxis=dict(visible=True)),
+       |            showlegend=show_legend,
+       |            width=600,
+       |            height=600
+       |        )
+       |"""
+  }
+
+  override def generatePythonCode(): String = {
+    val finalCode =
+      pyb"""
+         |from pytexera import *
+         |import numpy as np
+         |import plotly.graph_objects as go
+         |import plotly.express as px
+         |import plotly.io
+         |
+         |class ProcessTableOperator(UDFTableOperator):
+         |
+         |    def render_error(self, error_msg):
+         |        return '''<h1>Radar Plot is not available.</h1>
+         |                  <p>Reason is: {} </p>
+         |               '''.format(error_msg)
+         |
+         |    @overrides
+         |    def process_table(self, table: Table, port: int):
+         |        if table.empty:
+         |            yield {'html-content': self.render_error("Input table is 
empty.")}
+         |            return
+         |
+         |        ${generateRadarPlotCode()}
+         |
+         |        html = plotly.io.to_html(fig, include_plotlyjs='cdn', 
auto_play=False, config={'responsive': True})
+         |        yield {'html-content': html}
+         |"""
+    finalCode.encode
+  }
+}
diff --git a/frontend/src/assets/operator_images/RadarPlot.png 
b/frontend/src/assets/operator_images/RadarPlot.png
new file mode 100644
index 0000000000..374a6e7773
Binary files /dev/null and b/frontend/src/assets/operator_images/RadarPlot.png 
differ

Reply via email to