diff --git a/core/gui/src/assets/operator_images/RadarPlot.png b/core/gui/src/assets/operator_images/RadarPlot.png
new file mode 100644
index 00000000000..374a6e77731
Binary files /dev/null and b/core/gui/src/assets/operator_images/RadarPlot.png differ
diff --git a/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala
index b053ff929a9..cd84afa7b4f 100644
--- a/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala
+++ b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala
@@ -113,6 +113,7 @@ import edu.uci.ics.amber.operator.visualization.lineChart.LineChartOpDesc
import edu.uci.ics.amber.operator.visualization.networkGraph.NetworkGraphOpDesc
import edu.uci.ics.amber.operator.visualization.pieChart.PieChartOpDesc
import edu.uci.ics.amber.operator.visualization.quiverPlot.QuiverPlotOpDesc
+import edu.uci.ics.amber.operator.visualization.radarPlot.RadarPlotOpDesc
import edu.uci.ics.amber.operator.visualization.rangeSlider.RangeSliderOpDesc
import edu.uci.ics.amber.operator.visualization.sankeyDiagram.SankeyDiagramOpDesc
import edu.uci.ics.amber.operator.visualization.scatter3DChart.Scatter3dChartOpDesc
@@ -173,6 +174,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[WordCloudOpDesc], name = "WordCloud"),
new Type(value = classOf[HtmlVizOpDesc], name = "HTMLVisualizer"),
new Type(value = classOf[UrlVizOpDesc], name = "URLVisualizer"),
diff --git a/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/visualization/radarPlot/RadarPlotLinePattern.java b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/visualization/radarPlot/RadarPlotLinePattern.java
new file mode 100644
index 00000000000..ed9a9e54656
--- /dev/null
+++ b/core/workflow-operator/src/main/scala/edu/uci/ics/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 edu.uci.ics.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/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
new file mode 100644
index 00000000000..5f66e9f14f5
--- /dev/null
+++ b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
@@ -0,0 +1,227 @@
+/*
+ * 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 edu.uci.ics.amber.operator.visualization.radarPlot
+
+import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
+import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle}
+import edu.uci.ics.amber.core.tuple.{AttributeType, Schema}
+import edu.uci.ics.amber.operator.PythonOperatorDescriptor
+import edu.uci.ics.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
+import edu.uci.ics.amber.core.workflow.OutputPort.OutputMode
+import edu.uci.ics.amber.core.workflow.{InputPort, OutputPort, PortIdentity}
+import edu.uci.ics.amber.operator.metadata.annotations.{
+ AutofillAttributeName,
+ AutofillAttributeNameList
+}
+
+@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[String] = _
+
+ @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: String = ""
+
+ @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: String = ""
+
+ @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(
+ "Radar Plot",
+ "View the result in a radar plot. A radar plot displays multivariate data on multiple axes arranged in a circular layout, allowing for comparison between different entities.",
+ OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP,
+ inputPorts = List(InputPort()),
+ outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT))
+ )
+
+ def generateRadarPlotCode(): String = {
+ def toPythonBool(value: Boolean): String = if (value) "True" else "False"
+
+ val attrList = selectedAttributes.map(attr => s""""$attr"""").mkString(", ")
+ val traceNameCol = traceNameAttribute match {
+ case "" | "-- No Selection --" => "None"
+ case col => s"'$col'"
+ }
+ val traceColorCol = traceColorAttribute match {
+ case "" | "-- No Selection --" => "None"
+ case col => s"'$col'"
+ }
+
+ s"""
+ | 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]) + "
" 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
+ | )
+ |""".stripMargin
+ }
+
+ override def generatePythonCode(): String = {
+ s"""
+ |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 '''
Reason is: {}
+ | '''.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} + |""".stripMargin + } +}