Skip to content

Commit d868b85

Browse files
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 <xinyual3@uci.edu>
1 parent 2b62539 commit d868b85

5 files changed

Lines changed: 270 additions & 2 deletions

File tree

common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ import org.apache.texera.amber.operator.visualization.nestedTable.NestedTableOpD
124124
import org.apache.texera.amber.operator.visualization.networkGraph.NetworkGraphOpDesc
125125
import org.apache.texera.amber.operator.visualization.pieChart.PieChartOpDesc
126126
import org.apache.texera.amber.operator.visualization.quiverPlot.QuiverPlotOpDesc
127+
import org.apache.texera.amber.operator.visualization.radarPlot.RadarPlotOpDesc
127128
import org.apache.texera.amber.operator.visualization.radarChart.RadarChartOpDesc
128129
import org.apache.texera.amber.operator.visualization.rangeSlider.RangeSliderOpDesc
129130
import org.apache.texera.amber.operator.visualization.sankeyDiagram.SankeyDiagramOpDesc
@@ -196,6 +197,7 @@ trait StateTransferFunc
196197
new Type(value = classOf[RangeSliderOpDesc], name = "RangeSlider"),
197198
new Type(value = classOf[PieChartOpDesc], name = "PieChart"),
198199
new Type(value = classOf[QuiverPlotOpDesc], name = "QuiverPlot"),
200+
new Type(value = classOf[RadarPlotOpDesc], name = "RadarPlot"),
199201
new Type(value = classOf[RadarChartOpDesc], name = "RadarChart"),
200202
new Type(value = classOf[WordCloudOpDesc], name = "WordCloud"),
201203
new Type(value = classOf[HtmlVizOpDesc], name = "HTMLVisualizer"),

common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarChart/RadarChartOpDesc.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ package org.apache.texera.amber.operator.visualization.radarChart
2222
import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
2323
import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle}
2424
import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
25-
import org.apache.texera.amber.core.workflow.OutputPort.OutputMode
26-
import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity}
25+
import org.apache.texera.amber.core.workflow.PortIdentity
2726
import org.apache.texera.amber.operator.PythonOperatorDescriptor
2827
import org.apache.texera.amber.operator.metadata.annotations.{
2928
AutofillAttributeName,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.texera.amber.operator.visualization.radarPlot;
20+
21+
import com.fasterxml.jackson.annotation.JsonValue;
22+
23+
public enum RadarPlotLinePattern {
24+
SOLID("solid"),
25+
DASH("dash"),
26+
DOT("dot");
27+
private final String linePattern;
28+
29+
RadarPlotLinePattern(String linePattern) {
30+
this.linePattern = linePattern;
31+
}
32+
33+
@JsonValue
34+
public String getLinePattern() {
35+
return this.linePattern;
36+
}
37+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.texera.amber.operator.visualization.radarPlot
21+
22+
import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
23+
import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle}
24+
import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
25+
import org.apache.texera.amber.operator.metadata.annotations.{
26+
AutofillAttributeName,
27+
AutofillAttributeNameList
28+
}
29+
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
30+
import org.apache.texera.amber.operator.PythonOperatorDescriptor
31+
import org.apache.texera.amber.core.workflow.PortIdentity
32+
import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString
33+
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder
34+
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext
35+
36+
@JsonSchemaInject(json = """
37+
{
38+
"attributeTypeRules": {
39+
"selectedAttributes": {
40+
"enum": ["integer", "long", "double"]
41+
}
42+
}
43+
}
44+
""")
45+
class RadarPlotOpDesc extends PythonOperatorDescriptor {
46+
@JsonProperty(value = "selectedAttributes", required = true)
47+
@JsonSchemaTitle("Axes")
48+
@JsonPropertyDescription("Numeric columns to use as radar axes")
49+
@AutofillAttributeNameList
50+
var selectedAttributes: List[EncodableString] = _
51+
52+
@JsonProperty(value = "traceNameAttribute", defaultValue = "No Selection", required = false)
53+
@JsonSchemaTitle("Trace Name Column")
54+
@JsonPropertyDescription("Optional - Select a column to use for naming each radar trace")
55+
@AutofillAttributeName
56+
var traceNameAttribute: EncodableString = ""
57+
58+
@JsonProperty(
59+
value = "traceColorAttribute",
60+
defaultValue = "No Selection",
61+
required = false
62+
)
63+
@JsonSchemaTitle("Trace Color Column")
64+
@JsonPropertyDescription(
65+
"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)"
66+
)
67+
@AutofillAttributeName
68+
var traceColorAttribute: EncodableString = ""
69+
70+
@JsonProperty(value = "linePattern", defaultValue = "solid", required = true)
71+
@JsonPropertyDescription("Pattern of the lines connecting points on the radar plot")
72+
var linePattern: RadarPlotLinePattern = _
73+
74+
@JsonProperty(value = "maxNormalize", defaultValue = "true", required = true)
75+
@JsonSchemaTitle("Max Normalize")
76+
@JsonPropertyDescription(
77+
"Normalize radar plot values by scaling them relative to the maximum value on their respective axes"
78+
)
79+
var maxNormalize: Boolean = true
80+
81+
@JsonProperty(value = "fillTrace", defaultValue = "true", required = true)
82+
@JsonSchemaTitle("Fill Trace")
83+
@JsonPropertyDescription("Fill the area within each radar trace")
84+
var fillTrace: Boolean = true
85+
86+
@JsonProperty(value = "showMarkers", defaultValue = "true", required = true)
87+
@JsonSchemaTitle("Show Point Markers")
88+
@JsonPropertyDescription("Display point markers on the radar plot")
89+
var showMarkers: Boolean = true
90+
91+
@JsonProperty(value = "showLegend", defaultValue = "true", required = false)
92+
@JsonSchemaTitle("Show Legend")
93+
@JsonPropertyDescription(
94+
"Display the legend (note: without the legend, you are unable to selectively hide or show traces in the plot)"
95+
)
96+
var showLegend: Boolean = true
97+
98+
override def getOutputSchemas(
99+
inputSchemas: Map[PortIdentity, Schema]
100+
): Map[PortIdentity, Schema] = {
101+
val outputSchema = Schema()
102+
.add("html-content", AttributeType.STRING)
103+
Map(operatorInfo.outputPorts.head.id -> outputSchema)
104+
}
105+
106+
override def operatorInfo: OperatorInfo =
107+
OperatorInfo.forVisualization(
108+
"Radar Plot",
109+
"View the result in a radar plot.",
110+
OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP
111+
)
112+
113+
private def toPythonBool(value: Boolean): String = if (value) "True" else "False"
114+
115+
private def optionalColumnExpr(column: EncodableString): PythonTemplateBuilder =
116+
Option(column).filterNot(col => col.isEmpty || col == "No Selection") match {
117+
case Some(col) => pyb"$col"
118+
case None => pyb"None"
119+
}
120+
121+
def generateRadarPlotCode(): PythonTemplateBuilder = {
122+
val attributes = Option(selectedAttributes).getOrElse(Nil)
123+
val attrList = attributes.map(attr => pyb"$attr").mkString(", ")
124+
val traceNameCol = optionalColumnExpr(traceNameAttribute)
125+
val traceColorCol = optionalColumnExpr(traceColorAttribute)
126+
127+
pyb"""
128+
| categories = [$attrList]
129+
| if not categories:
130+
| yield {'html-content': self.render_error("No columns selected as axes.")}
131+
| return
132+
|
133+
| trace_name_col = $traceNameCol
134+
| trace_color_col = $traceColorCol
135+
| line_pattern = "${linePattern.getLinePattern}"
136+
| max_normalize = ${toPythonBool(maxNormalize)}
137+
| fill_trace = ${toPythonBool(fillTrace)}
138+
| show_markers = ${toPythonBool(showMarkers)}
139+
| show_legend = ${toPythonBool(showLegend)}
140+
|
141+
| selected_table_df = table[categories].astype(float)
142+
| selected_table = selected_table_df.values
143+
|
144+
| trace_names = (
145+
| table[trace_name_col].values if trace_name_col
146+
| else np.full(len(table), "", dtype=object)
147+
| )
148+
|
149+
| trace_colors = [None] * len(table)
150+
| if trace_color_col:
151+
| unique_vals = table[trace_color_col].unique()
152+
| color_map = {val: px.colors.qualitative.Plotly[idx % len(px.colors.qualitative.Plotly)]
153+
| for idx, val in enumerate(unique_vals)}
154+
| nan_color = '#000000'
155+
| trace_colors = table[trace_color_col].map(color_map).fillna(nan_color).values
156+
|
157+
| hover_texts = []
158+
| for idx, row in enumerate(selected_table):
159+
| name_prefix = str(trace_names[idx]) + "<br>" if trace_names[idx] else ""
160+
| row_hover_texts = []
161+
| for attr, value in zip(categories, row):
162+
| row_hover_texts.append(name_prefix + attr + ": " + str(value))
163+
| hover_texts.append(row_hover_texts)
164+
|
165+
| if max_normalize:
166+
| max_vals = selected_table_df.max().values
167+
| max_vals[max_vals == 0] = 1
168+
| selected_table = selected_table / max_vals
169+
|
170+
| selected_table = np.nan_to_num(selected_table)
171+
|
172+
| fig = go.Figure()
173+
|
174+
| for idx, row in enumerate(selected_table):
175+
| # To connect ensure all points in the radar trace are connected
176+
| closed_row = row.tolist() + [row[0]]
177+
| closed_categories = categories + [categories[0]]
178+
| closed_hover_texts = hover_texts[idx] + [hover_texts[idx][0]]
179+
|
180+
| fig.add_trace(go.Scatterpolar(
181+
| r=closed_row,
182+
| theta=closed_categories,
183+
| fill='toself' if fill_trace else 'none',
184+
| name=str(trace_names[idx]) if trace_names[idx] else "",
185+
| text=closed_hover_texts,
186+
| hoverinfo="text",
187+
| mode="lines+markers" if show_markers else "lines",
188+
| line=dict(dash=line_pattern, color=trace_colors[idx] if trace_colors[idx] else None),
189+
| marker=dict(color=trace_colors[idx]) if trace_colors[idx] else {}
190+
| ))
191+
|
192+
| fig.update_layout(
193+
| polar=dict(radialaxis=dict(visible=True)),
194+
| showlegend=show_legend,
195+
| width=600,
196+
| height=600
197+
| )
198+
|"""
199+
}
200+
201+
override def generatePythonCode(): String = {
202+
val finalCode =
203+
pyb"""
204+
|from pytexera import *
205+
|import numpy as np
206+
|import plotly.graph_objects as go
207+
|import plotly.express as px
208+
|import plotly.io
209+
|
210+
|class ProcessTableOperator(UDFTableOperator):
211+
|
212+
| def render_error(self, error_msg):
213+
| return '''<h1>Radar Plot is not available.</h1>
214+
| <p>Reason is: {} </p>
215+
| '''.format(error_msg)
216+
|
217+
| @overrides
218+
| def process_table(self, table: Table, port: int):
219+
| if table.empty:
220+
| yield {'html-content': self.render_error("Input table is empty.")}
221+
| return
222+
|
223+
| ${generateRadarPlotCode()}
224+
|
225+
| html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False, config={'responsive': True})
226+
| yield {'html-content': html}
227+
|"""
228+
finalCode.encode
229+
}
230+
}
47.3 KB
Loading

0 commit comments

Comments
 (0)