///|
/// The direction of increasing polar angles; all angle inputs are radians.
pub(all) enum PolarDirection {
CounterClockwise
Clockwise
} derive(Eq, Debug)
///|
pub extend PolarDirection with Eq::{equal, not_equal}
///|
pub extend PolarDirection with @debug.Debug::{to_repr}
///|
priv struct PolarState {
mut radius_max : Double?
mut theta_offset : Double
mut direction : PolarDirection
mut theta_ticks : Array[Double]
mut theta_labels : Array[String]
mut radius_ticks : Array[Double]?
mut radius_labels : Array[String]
closed : Array[Bool]
hit_data : Array[@render.PolarHitData]
mut radar_labels : Array[String]?
mut cartesian_grid : Array[(Bool, String, Double, Bool, String, Double)]
mut cartesian_margins : (Double, Double)
}
///|
/// A polar panel retaining original theta/radius samples independently of its
/// projected SVG. Plot helpers return no mutable Cartesian Line2D handle.
pub struct PolarAxes {
priv axes : Axes
}
///|
/// Add an independent full-circle polar panel at a normalized Figure rectangle.
pub fn Figure::add_polar_axes(
self : Figure,
left : Double,
bottom : Double,
width : Double,
height : Double,
) -> PolarAxes raise @gridspec.LayoutError {
let axes = self.add_axes(left, bottom, width, height)
axes.state.val.polar = Some({
radius_max: None,
theta_offset: 0.0,
direction: CounterClockwise,
theta_ticks: [
for i in 0..<8 => i.to_double() * @math.PI / 4.0
],
theta_labels: [
for i in 0..<8 => "\{i * 45}°"
],
radius_ticks: None,
radius_labels: [],
closed: [],
hit_data: [],
radar_labels: None,
cartesian_grid: [
polar_grid_signature(axes.x_axis()),
polar_grid_signature(axes.y_axis()),
],
cartesian_margins: axes.margins(),
})
{ axes, }
}
///|
fn PolarAxes::config(self : PolarAxes) -> PolarState {
self.axes.state.val.polar.unwrap()
}
///|
fn AxesData::polar_projection(self : AxesData) -> @render.PolarProjection? {
self.polar.map(p => {
@render.polar_projection(
p.radius_max,
p.theta_offset,
p.direction == Clockwise,
p.theta_ticks,
p.theta_labels,
p.radius_ticks,
p.radius_labels,
p.closed,
cartesian_compatible=p.cartesian_grid ==
[polar_grid_signature(self.x_axis), polar_grid_signature(self.y_axis)] &&
p.cartesian_margins == (self.x_margin, self.y_margin),
hit_data=p.hit_data,
)
})
}
///|
fn polar_finite(value : Double) -> Bool {
!value.is_nan() && !value.is_inf()
}
///|
fn polar_grid_signature(
axis : @axis.Axis,
) -> (Bool, String, Double, Bool, String, Double) {
(
axis.grid_visible(),
axis.grid_color(),
axis.grid_width(),
axis.minor_grid_visible(),
axis.minor_grid_color(),
axis.minor_grid_width(),
)
}
///|
fn polar_nonnegative(
value : Double,
name : String,
) -> Unit raise @core.DataError {
if !polar_finite(value) || value < 0.0 {
raise @core.DataError::InvalidReference(
message="polar \{name} must be finite and nonnegative",
)
}
}
///|
/// Draw straight chords through projected samples. A closed path adds only a
/// rendered closing vertex, and closes only when every original pair is valid.
pub fn PolarAxes::plot(
self : PolarAxes,
theta : Array[Double],
radius : Array[Double],
closed~ : Bool = false,
color~ : String = "",
linewidth~ : Double = 1.5,
label~ : String = "",
) -> Unit raise @core.DataError {
polar_nonnegative(linewidth, "linewidth")
let points = @core.series(theta, radius).points()
let resolved = self.axes.resolve_color(color)
self.axes.push_artist(Line(points, @core.style(resolved, width=linewidth)))
self.config().closed.push(closed)
self.config().hit_data.push(@render.polar_hit_data(points, label))
self.axes.add_auto_legend(label, resolved, LegendKind::Line)
}
///|
/// Draw filled sectors centered at theta, with one required angular width in
/// radians. Radii begin at zero; nonpositive or missing samples keep their slots.
pub fn PolarAxes::bar(
self : PolarAxes,
theta : Array[Double],
radius : Array[Double],
width~ : Double,
color~ : String = "",
label~ : String = "",
) -> Unit raise @core.DataError {
if !polar_finite(width) || width <= 0.0 || width > 2.0 * @math.PI {
raise @core.DataError::InvalidReference(
message="polar bar width must be finite and in (0, 2pi] radians",
)
}
let points = @core.series(theta, radius).points()
let resolved = self.axes.resolve_color(color)
self.axes.push_artist(
Bar(points, @core.style(resolved, width=0.0, fill=resolved)),
)
self.config().closed.push(false)
self.config().hit_data.push(@render.polar_bar_hit_data(points, label, width))
self.axes.add_auto_legend(label, resolved, LegendKind::Patch)
}
///|
/// Draw circular markers; size is their SVG pixel radius, not marker area.
pub fn PolarAxes::scatter(
self : PolarAxes,
theta : Array[Double],
radius : Array[Double],
color~ : String = "",
size~ : Double = 3.5,
label~ : String = "",
) -> Unit raise @core.DataError {
polar_nonnegative(size, "marker size")
let points = @core.series(theta, radius).points()
let resolved = self.axes.resolve_color(color)
self.axes.push_artist(
Scatter(points, @core.style(resolved, width=0.0, radius=size)),
)
self.config().closed.push(false)
self.config().hit_data.push(
@render.polar_hit_data(points, label, scatter=true),
)
self.axes.add_auto_legend(label, resolved, LegendKind::Marker)
}
///|
/// Draw a closed outline with equally spaced categories. Multiple radar calls
/// in one panel require exactly the same ordered labels.
pub fn PolarAxes::radar(
self : PolarAxes,
values : Array[Double],
labels : Array[String],
color~ : String = "",
linewidth~ : Double = 1.5,
label~ : String = "",
) -> Unit raise @core.DataError {
if values.length() != labels.length() {
raise @core.DataError::LengthMismatch(
expected=values.length(),
actual=labels.length(),
)
}
if values.length() < 3 {
raise @core.DataError::InvalidReference(
message="radar needs at least three categories",
)
}
polar_nonnegative(linewidth, "linewidth")
let config = self.config()
if config.radar_labels is Some(previous) && previous != labels {
raise @core.DataError::InvalidReference(
message="radar category labels must match this panel",
)
}
let theta = [
for i in 0.. {
2.0 * @math.PI * i.to_double() / values.length().to_double()
}
]
self.plot(theta, values, closed=true, color~, linewidth~, label~)
let last = config.hit_data.length() - 1
config.hit_data[last] = config.hit_data[last].with_categories(labels)
config.radar_labels = Some(labels.copy())
config.theta_ticks = theta
config.theta_labels = labels.copy()
}
///|
pub fn PolarAxes::set_radius_max(
self : PolarAxes,
value : Double,
) -> Unit raise @core.DataError {
if !polar_finite(value) || value <= 0.0 {
raise @core.DataError::InvalidReference(
message="polar radius maximum must be finite and positive",
)
}
self.config().radius_max = Some(value)
}
///|
pub fn PolarAxes::set_theta_offset(
self : PolarAxes,
radians : Double,
) -> Unit raise @core.DataError {
if !polar_finite(radians) {
raise @core.DataError::InvalidReference(
message="polar theta offset must be finite radians",
)
}
self.config().theta_offset = radians
}
///|
pub fn PolarAxes::set_direction(
self : PolarAxes,
direction : PolarDirection,
) -> Unit {
self.config().direction = direction
}
///|
fn validate_polar_ticks(
values : Array[Double],
labels : Array[String],
radial : Bool,
) -> Unit raise @core.DataError {
if !labels.is_empty() && labels.length() != values.length() {
raise @core.DataError::LengthMismatch(
expected=values.length(),
actual=labels.length(),
)
}
for value in values {
if !polar_finite(value) || (radial && value < 0.0) {
raise @core.DataError::InvalidReference(
message="polar ticks must be finite; radius ticks must be nonnegative",
)
}
}
}
///|
pub fn PolarAxes::set_theta_ticks(
self : PolarAxes,
values : Array[Double],
labels~ : Array[String] = [],
) -> Unit raise @core.DataError {
validate_polar_ticks(values, labels, false)
self.config().theta_ticks = values.copy()
self.config().theta_labels = labels.copy()
}
///|
pub fn PolarAxes::set_radius_ticks(
self : PolarAxes,
values : Array[Double],
labels~ : Array[String] = [],
) -> Unit raise @core.DataError {
validate_polar_ticks(values, labels, true)
self.config().radius_ticks = Some(values.copy())
self.config().radius_labels = labels.copy()
}
///|
pub fn PolarAxes::set_title(self : PolarAxes, value : String) -> Unit {
self.axes.set_title(value)
}
///|
pub fn PolarAxes::set_legend(
self : PolarAxes,
entries : Array[LegendEntry],
) -> Unit {
self.axes.set_legend(entries)
}
///|
pub fn PolarAxes::legend(
self : PolarAxes,
entries : Array[LegendEntry],
) -> Unit {
self.set_legend(entries)
}
///|
pub fn PolarAxes::set_legend_position(
self : PolarAxes,
position : LegendPosition,
) -> Unit {
self.axes.set_legend_position(position)
}
///|
pub fn PolarAxes::legend_entries(self : PolarAxes) -> Array[LegendEntry] {
self.axes.legend_entries()
}
///|
/// Remove series/title/legend while retaining the polar projection and settings.
pub fn PolarAxes::clear(self : PolarAxes) -> Unit {
self.axes.clear()
}