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 fee6f8caa2 feat: add a new wind rose chart operator (#4224)
fee6f8caa2 is described below
commit fee6f8caa2cfd7142a55eab8a6edfd7eabd7117c
Author: Julie Cao <[email protected]>
AuthorDate: Thu Apr 16 23:05:37 2026 -0700
feat: add a new wind rose chart operator (#4224)
### What changes were proposed in this PR?
<img width="3023" height="1714" alt="image"
src="https://github.com/user-attachments/assets/dd6e94c0-70da-4c5d-bedb-27f78bab403b"
/>
This change adds a new wind rose chart operator, which visualizes how a
magnitude (radial value) is distributed across different directions
(angular values) and optionally grouped by a categorical variable
(color).
In a wind rose chart:
- Each angular segment represents a direction (e.g., N, NE, E).
- The length of each bar from the center represents the magnitude or
frequency for that direction.
- Color can optionally represent an additional grouping variable, such
as strength or category.
This visualization is useful for understanding directional
distributions, identifying dominant directions, and comparing patterns
across different groups or conditions. The operator takes in 2 or 3
inputs: the radial values, the angular values, and optionally a
color/grouping variable.
### Any related issues, documentation, discussions?
No related issues, documentation, or discussions.
### How was this PR tested?
PR is tested with existing test cases.
### Was this PR authored or co-authored using generative AI tooling?
No.
---------
Co-authored-by: Xinyuan Lin <[email protected]>
---
.../apache/texera/amber/operator/LogicalOp.scala | 2 +
.../windRoseChart/WindRoseChartOpDesc.scala | 128 +++++++++++++++++++++
.../src/assets/operator_images/WindRoseChart.png | Bin 0 -> 24191 bytes
3 files changed, 130 insertions(+)
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 1f6e444c46..2d2bd24e8a 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
@@ -136,6 +136,7 @@ import
org.apache.texera.amber.operator.visualization.treeplot.TreePlotOpDesc
import org.apache.texera.amber.operator.visualization.urlviz.UrlVizOpDesc
import
org.apache.texera.amber.operator.visualization.volcanoPlot.VolcanoPlotOpDesc
import
org.apache.texera.amber.operator.visualization.waterfallChart.WaterfallChartOpDesc
+import
org.apache.texera.amber.operator.visualization.windRoseChart.WindRoseChartOpDesc
import org.apache.texera.amber.operator.visualization.wordCloud.WordCloudOpDesc
import org.apache.commons.lang3.builder.{EqualsBuilder, HashCodeBuilder,
ToStringBuilder}
import org.apache.texera.amber.operator.sklearn.testing.SklearnTestingOpDesc
@@ -190,6 +191,7 @@ trait StateTransferFunc
new Type(value = classOf[AggregateOpDesc], name = "Aggregate"),
new Type(value = classOf[LineChartOpDesc], name = "LineChart"),
new Type(value = classOf[WaterfallChartOpDesc], name = "WaterfallChart"),
+ new Type(value = classOf[WindRoseChartOpDesc], name = "WindRoseChart"),
new Type(value = classOf[BarChartOpDesc], name = "BarChart"),
new Type(value = classOf[RangeSliderOpDesc], name = "RangeSlider"),
new Type(value = classOf[PieChartOpDesc], name = "PieChart"),
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/windRoseChart/WindRoseChartOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/windRoseChart/WindRoseChartOpDesc.scala
new file mode 100644
index 0000000000..4b930d541f
--- /dev/null
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/windRoseChart/WindRoseChartOpDesc.scala
@@ -0,0 +1,128 @@
+/*
+ * 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.windRoseChart
+
+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.core.workflow.OutputPort.OutputMode
+import
org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext
+import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString
+import org.apache.texera.amber.core.workflow.{InputPort, OutputPort,
PortIdentity}
+import org.apache.texera.amber.operator.PythonOperatorDescriptor
+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 WindRoseChartOpDesc extends PythonOperatorDescriptor {
+
+ @JsonProperty(value = "rColumn", required = true)
+ @JsonSchemaTitle("Radial Values (r)")
+ @JsonPropertyDescription("Numeric values representing magnitude (e.g.,
frequency)")
+ @AutofillAttributeName
+ @NotNull(message = "Radial Values (r) column must be selected.")
+ var rColumn: EncodableString = _
+
+ @JsonProperty(value = "thetaColumn", required = true)
+ @JsonSchemaTitle("Angular Values (θ)")
+ @JsonPropertyDescription("Direction or angle categories (e.g., N, NE, E)")
+ @AutofillAttributeName
+ @NotNull(message = "Angular Values (θ) column must be selected.")
+ var thetaColumn: EncodableString = _
+
+ @JsonProperty(value = "colorColumn", required = false)
+ @JsonSchemaTitle("Color Group")
+ @JsonPropertyDescription("Optional grouping column (e.g., wind strength)")
+ @AutofillAttributeName
+ var colorColumn: EncodableString = _
+
+ override def operatorInfo: OperatorInfo =
+ OperatorInfo(
+ userFriendlyName = "Wind Rose Chart",
+ operatorDescription = "Displays wind distribution using a polar bar
chart",
+ operatorGroupName =
OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP,
+ inputPorts = List(InputPort()),
+ outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT))
+ )
+
+ override def getOutputSchemas(
+ inputSchemas: Map[PortIdentity, Schema]
+ ): Map[PortIdentity, Schema] = {
+ val outputSchema = Schema()
+ .add("html-content", AttributeType.STRING)
+ Map(operatorInfo.outputPorts.head.id -> outputSchema)
+ }
+
+ def createPlotlyFigure(): PythonTemplateBuilder = {
+ val colorArg =
+ if (colorColumn != null && colorColumn.nonEmpty)
+ pyb"""
+ | color=$colorColumn,
+ |"""
+ else
+ pyb""
+
+ pyb"""
+ | fig = px.bar_polar(
+ | table,
+ | r=$rColumn,
+ | theta=$thetaColumn,
+ |$colorArg
+ | color_discrete_sequence=px.colors.sequential.Plasma_r
+ | )
+ |"""
+ }
+
+ override def generatePythonCode(): String = {
+ val finalCode =
+ pyb"""
+ |from pytexera import *
+ |
+ |import plotly.graph_objects as go
+ |import plotly.io
+ |import plotly.express as px
+ |
+ |class ProcessTableOperator(UDFTableOperator):
+ |
+ | # Generate custom error message as html string
+ | def render_error(self, error_msg) -> str:
+ | return '''<h1>Wind Rose chart is not available.</h1>
+ | <p>Reason is: {} </p>
+ | '''.format(error_msg)
+ |
+ | @overrides
+ | def process_table(self, table: Table, port: int) ->
Iterator[Optional[TableLike]]:
+ | if table.empty:
+ | yield {'html-content': self.render_error("input table is
empty.")}
+ | return
+ | if table[$rColumn].dtype.kind not in ["i", "u", "f"]:
+ | yield {'html-content': self.render_error(
+ | "Radial column must be numeric (int, float, or
double)."
+ | )}
+ | return
+ | ${createPlotlyFigure()}
+ | html = plotly.io.to_html(fig, include_plotlyjs='cdn',
auto_play=False)
+ | yield {'html-content': html}
+ |"""
+ finalCode.encode
+ }
+
+}
diff --git a/frontend/src/assets/operator_images/WindRoseChart.png
b/frontend/src/assets/operator_images/WindRoseChart.png
new file mode 100644
index 0000000000..f63c8e85bd
Binary files /dev/null and
b/frontend/src/assets/operator_images/WindRoseChart.png differ