Introduction
In this article, we are going to analyze ORC data for sailboats, and look into how we can create polar plots in Elixir through VegaLite.
What exactly is polar data?
We’ll use Brian’s sailboat, LittleWing, as the example data set. LittleWing is a Beneteau First 24 SE. A few years ago Brian had his boat measured and some predictive performance data generated based upon those measurements. In sailing these are called “Polars” as they’re mapped on a polar coordinate system and will show how fast the boat should go for a given wind speed and angle of attack. However, these datasets are typically sparse and don’t contain a comprehensive range of values. Below we have a table from LittleWing’s polar package that represents the boat’s speed potential given the wind speed (True Wind Speed, or TWS) and the angle of attack with respect to the wind direction (True Wind Angle, or TWA).
TWS by TWA | 40º | 50º | 60º | 70º | 80º | 90º | 100º | 110º | 120º |
---|---|---|---|---|---|---|---|---|---|
6kts | 4.4 | 5.1 | 5.59 | 5.99 | 6.20 | 6.37 | 6.374 | 6.25 | 6.02 |
8kts | 5.41 | 6.05 | 6.37 | 6.54 | 6.72 | 6.88 | 6.99 | 6.98 | 6.77 |
10kts | 5.93 | 6.38 | 6.68 | 6.9 | 7.1 | 7.2 | 7.35 | 7.48 | 7.37 |
The fact that we have multiple functions that relate a value to an angle (3, in the example above) tells us that we’re looking at multiple sets of polar data. A polar plot is a circular plot in which each point is represented by an angle (commonly called theta, the greek alphabet letter) and an absolute distance (commonly called r, for radius).
How to plot polar data
Now that we have a dataset in our hands, we can use VegaLite to visualize it. For this, we need to deal with the fact that the underlying vega-lite
JS library doesn’t support polar plots. It does, however, support arc plots and normal cartesian line plots separately. We can also take advantage of the layering concept it exposes to build our custom polar plot visualization.
First, we need to install the dependencies:
Mix.install([
{:kino_vega_lite, "~> 0.1.3"},
{:jason, "~> 1.2"} # Jason is needed for the SVG export we'll use.
])
Creating the grid
With the dependencies installed, we can start creating our PolarPlot
module which will handle the grid generation and data plotting.
First, let’s take a look into how we generate the polar grid.
defmodule PolarPlot do
alias VegaLite, as: Vl
def plot_grid(opts \\ []) do
opts =
Keyword.validate!(opts, [
:height,
:width,
:color,
:radius_marks,
angle_marks: [0, 90, 180, 270],
theta_offset: 0,
opacity: 0.5,
stroke_color: "white",
stroke_opacity: 1
])
width = height = max(opts[:height], opts[:width])
grid_layers = angle_layers(opts)
radius_layers = radius_layers(opts)
Vl.new(height: height, width: width)
|> Vl.data_from_values(%{_r: [1]})
|> Vl.layers(grid_layers ++ radius_layers)
end
The plot_grid
function receives a keyword list that configures the plot. This module uses the Keyword.validate!
function from Elixir 1.13 to ensure the options are passed correctly.
Then, we can take a look at our angle_layers
and radius_layers
functions separately.
defp rad(angle), do: angle * :math.pi() / 180
defp angle_layers(opts) do
angle_marks_input = opts[:angle_marks]
theta_offset = opts[:theta_offset]
angle_marks = [0 | Enum.sort(angle_marks_input)] |> Enum.map(fn x -> x + theta_offset end)
angle_marks2 = tl(angle_marks) ++ [360 + theta_offset]
[angle_marks, angle_marks2]
|> Enum.zip_with(fn [t, t2] ->
label =
if t != 0 or (t == 0 and 0 in angle_marks_input) do
Vl.new()
|> Vl.mark(:text,
text: to_string(t - theta_offset) <> "º",
theta: "#{rad(t)}",
radius: [expr: "min(width, height) * 0.55"]
)
else
[]
end
[
Vl.new()
|> Vl.mark(:arc,
theta: "#{rad(t)}",
theta2: "#{rad(t2)}",
stroke: opts[:stroke_color],
stroke_opacity: opts[:stroke_opacity],
opacity: opts[:opacity],
color: opts[:color]
),
label
]
end)
|> List.flatten()
end
The angle_layers
function utilizes the angle marks parameters as well as the plot’s graphical radius (inferred from the width and height of the plot) to plot a sequence of arcs that span a full circle, but stops at each entry in the angle_marks
option. This will yield a graph with radial strokes.
defp radius_layers(opts) do
radius_marks = opts[:radius_marks]
max_radius = Enum.max(radius_marks)
{theta, theta2} = Enum.min_max(opts[:angle_marks])
radius_marks_vl =
Enum.map(radius_marks, fn r ->
Vl.mark(Vl.new(), :arc,
radius: [expr: "#{r / max_radius} * min(width, height)/2"],
radius2: [expr: "#{r / max_radius} * min(width, height)/2 + 1"],
theta: theta |> rad() |> to_string(),
theta2: theta2 |> rad() |> to_string(),
color: opts[:stroke_color],
opacity: opts[:stroke_opacity]
)
end)
radius_ruler_vl = [
Vl.new()
|> Vl.data_from_values(%{
r: radius_marks,
theta: Enum.map(radius_marks, fn _ -> :math.pi() / 4 end)
})
|> Vl.mark(:text,
color: "black",
radius: [expr: "datum.r * min(width, height) / (2 * #{max_radius})"],
theta: :math.pi() / 2,
dy: 10,
dx: -10
)
|> Vl.encode_field(:text, "r", type: :quantitative)
]
radius_marks_vl ++ radius_ruler_vl
end
The radius_marks
function serves two purposes. The first one is to plot concentric arcs of width 1 (represented in the variable radius_marks_vl
). These arcs will serve as the radial dimension grid. The second purpose is that this function will also label said arcs, marking the values which each one represents.
This all means that the plot_grid
function takes a base layer that marks the angles and subsequent layers that mark the radii to build a radial plot. Next, we’ll understand how plot_data
uses the cartesian plot to plot lines over this existing grid.
Plotting the data
We can obtain our grid base layer by calling grid_vl = PolarPlot.plot_grid(r, opts)
. Then, we can pass that layer into the plot_data
function to generate our final polar plot.
To understand how the function works, one needs to understand that we can transform rectangular (cartesian) coordinates into polar coordinates and vice versa. The base formula for converting (r, theta)
into (x, y)
is x = r.cos(theta)
and y = r.sin(theta)
.
The main highlights in the function below are that we calculate x_linear
and y_linear
with the aforementioned formula, and then calculate x
and y
using the 2D rotation matrix to account for the theta_offset
option.
It is also important to note that we turn off all of the labels, axes, and grid so that only the plot itself is generated.
Also, the scale
is such that the origin is placed at the center of the generated (hidden) grid, so that it coincides with the polar grid’s center.
Finally, the data
parameter is a list of tuples that contain the r
and theta
data for a given plot as well as the mark
type and mark_options
that should be used.
This will become useful for plotting interpolated data, which we’ll look into in a future post. The :grouping
and :legend_name
options are used so we can group multiple data
entries with the same color and to set the legend title accordingly.
def plot_data(vl, data, opts \\ []) do
pi = :math.pi()
legend_name = opts[:legend_name] || ""
theta_offset = opts[:theta_offset] || 0
radius_marks = opts[:radius_marks]
max_radius = Enum.max(radius_marks)
Vl.new()
|> Vl.config(style: [cell: [stroke: "transparent"]])
|> Vl.layers([
vl
| for {r_in, theta_in, mark, mark_opts} <- data do
grouping = mark_opts[:grouping]
Vl.new()
|> Vl.data_from_values(%{
:r => r_in,
:theta => theta_in,
legend_name => List.duplicate(grouping, length(theta_in))
})
|> Vl.transform(calculate: "datum.r * cos(datum.theta * #{pi / 180})", as: "x_linear")
|> Vl.transform(calculate: "datum.r * sin(datum.theta * #{pi / 180})", as: "y_linear")
|> Vl.transform(
calculate:
"datum.x_linear * cos(#{rad(theta_offset)}) - datum.y_linear * sin(#{rad(theta_offset)})",
as: "x"
)
|> Vl.transform(
calculate:
"datum.x_linear * sin(#{rad(theta_offset)}) + datum.y_linear * cos(#{rad(theta_offset)})",
as: "y"
)
|> Vl.mark(mark, mark_opts)
|> Vl.encode_field(:color, legend_name, type: :nominal)
|> Vl.encode_field(:x, "y",
type: :quantitative,
scale: [
domain: [-max_radius, max_radius]
],
axis: [
grid: false,
ticks: false,
domain_opacity: 0,
labels: false,
title: false,
domain: false,
offset: 50
]
)
|> Vl.encode_field(:y, "x",
type: :quantitative,
scale: [
domain: [-max_radius, max_radius]
],
axis: [
grid: false,
ticks: false,
domain_opacity: 0,
labels: false,
title: false,
domain: false,
offset: 50
]
)
|> Vl.encode_field(:order, "theta")
end
])
end
Plotting our first polar graph
Now that we understand the PolarPlot
module we created, we can use it to plot the data we analyzed before.
The code below plots the data in the table above and saves it to an SVG file
opts = [
theta_offset: 0,
width: 600,
height: 600,
opacity: 0.5,
stroke_color: "white",
stroke_opacity: 0.5,
angle_marks: [30, 45, 60, 75, 90, 105, 120],
radius_marks: [4, 5, 6, 7, 8]
]
theta = Enum.to_list(40..120//10)
r6 = [4.4 , 5.1 , 5.59, 5.99, 6.20, 6.37, 6.374, 6.25, 6.02]
r8 = [5.41, 6.05, 6.37, 6.54, 6.72, 6.88, 6.99, 6.98, 6.77]
r10 = [5.93, 6.38, 6.68, 6.9, 7.1, 7.2, 7.35, 7.48, 7.37]
vl_grid = PolarPlot.plot_grid(opts)
data = [
{r6, theta, :line, grouping: 6, point: true},
{r8, theta, :line, grouping: 8, point: true},
{r10, theta, :line, grouping: 10, point: true}
]
vl_data = PolarPlot.plot_data(vl_grid, data, [{:stroke_opacity, 1} | opts])
svg_contents = VegaLite.Export.to_svg(vl_data)
File.write("/tmp/plot.svg", svg_contents)
vl_data
We can see that because we only have a handful of points and the functions a plotted in a hidden cartesian grid, the graph just plot’s straight lines between points instead of curves we would expect in a polar plot. We can improve on that through the :interpolate
mark option:
...
data = [
{r6, theta, :line, grouping: 6, point: true, interpolate: "natural"},
{r8, theta, :line, grouping: 8, point: true, interpolate: "natural"},
{r10, theta, :line, grouping: 10, point: true, interpolate: "natural"}
]
...
Now we have a graph that’s closer to what we would expect. However, this doesn’t let us retrieve the values of the smooth function nor can we extrapolate the data at the edges. This is where interpolation comes in. It uses algorithms that calculate inferred functions from the given points - in fact, in the next post we will be implementing the one used for the last plot.