use crate::{
DataTable,
diagram::pixels,
error::ChartWarning,
layout::{LABEL_SIZE, count, fit_text, format_value, push_title, title_extra},
metrics::TextMetrics,
scene::{Circle, Element, Line, Polyline, Scene, Text, TextAnchor},
spec::{ChartSpec, PolarSpec, RINGS},
text,
};
const WEDGE: f64 = 0.8;
const ARC_STEP: f64 = 0.05;
fn polar(spec: &ChartSpec) -> &PolarSpec {
spec.polar
.as_ref()
.expect("validated polar charts carry a polar block")
}
fn angle(points: usize, index: usize) -> f64 {
std::f64::consts::TAU * count(index) / count(points) - std::f64::consts::FRAC_PI_2
}
fn at(center: (f64, f64), angle: f64, distance: f64) -> (f64, f64) {
(
center.0 + distance * angle.cos(),
center.1 + distance * angle.sin(),
)
}
fn value_name(spec: &ChartSpec) -> String {
polar(spec)
.value_label
.clone()
.unwrap_or_else(|| spec.locale.words().value.to_owned())
}
#[allow(clippy::too_many_lines)]
pub(crate) fn layout(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
let polar = polar(spec);
let points = polar.points.len();
let end = polar.end();
let compact = spec.width < crate::layout::NARROW;
let (width, height) = (f64::from(spec.width), f64::from(spec.height));
let margin = if compact { 12.0 } else { 24.0 };
let mut elements = Vec::new();
push_title(
&mut elements,
spec,
margin,
width - 2.0 * margin,
metrics,
warnings,
);
let top = 56.0 + title_extra(spec, width - 2.0 * margin, metrics);
let room = polar
.points
.iter()
.map(|point| metrics.width(&point.label, LABEL_SIZE))
.fold(0.0, f64::max)
.min(width * 0.22)
+ 16.0;
let area = height - top - 12.0;
let radius = (width / 2.0 - margin - room)
.min(area / 2.0 - 20.0)
.floor()
.max(30.0);
let center = (width / 2.0, top + area / 2.0);
for ring in 1..=RINGS {
elements.push(Element::Circle(Circle {
cx: center.0,
cy: center.1,
radius: radius * f64::from(ring) / f64::from(RINGS),
class: "chartlet-polar-ring",
series_index: None,
style_index: None,
tooltip: None,
}));
}
let style = spec.number_style();
for (index, point) in polar.points.iter().enumerate() {
let theta = angle(points, index);
let distance = radius * point.value / end;
let tooltip = Some(format!(
"{}: {}",
point.label,
format_value(point.value, style)
));
if polar.is_lollipops() {
let tip = at(center, theta, distance);
elements.push(Element::Line(Line {
x1: center.0,
y1: center.1,
x2: tip.0,
y2: tip.1,
class: "chartlet-polar-stem",
}));
elements.push(Element::Circle(Circle {
cx: tip.0,
cy: tip.1,
radius: 4.5,
class: "chartlet-polar-dot",
series_index: None,
style_index: None,
tooltip,
}));
} else if distance > 0.0 {
let half = std::f64::consts::TAU / count(points) * WEDGE / 2.0;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let steps = ((2.0 * half / ARC_STEP).ceil() as usize).max(2);
let mut outline = vec![center];
for step in 0..=steps {
outline.push(at(
center,
theta - half + 2.0 * half * count(step) / count(steps),
distance,
));
}
outline.push(center);
elements.push(Element::Polyline(Polyline {
points: outline,
class: "chartlet-polar-bar",
series_index: None,
style_index: None,
tooltip,
}));
}
}
for ring in 1..=RINGS {
let fraction = f64::from(ring) / f64::from(RINGS);
elements.push(Element::Text(Text {
x: center.0 + 4.0,
y: center.1 - radius * fraction - 3.0,
class: "chartlet-polar-tick",
anchor: TextAnchor::Start,
content: format_value(end * fraction, style),
}));
}
let mut placed: Vec<(f64, f64, f64, f64)> = Vec::new();
let mut omitted = false;
for (index, point) in polar.points.iter().enumerate() {
let theta = angle(points, index);
let (x, y) = at(center, theta, radius + 10.0);
let direction = theta.cos();
let anchor = if direction > 0.3 {
TextAnchor::Start
} else if direction < -0.3 {
TextAnchor::End
} else {
TextAnchor::Middle
};
let available = match anchor {
TextAnchor::Start => width - margin - x,
TextAnchor::End => x - margin,
TextAnchor::Middle => 2.0 * (x - margin).min(width - margin - x),
};
let y = if direction.abs() <= 0.3 && y < center.1 {
y - 6.0
} else {
y
};
let lift = if y > center.1 + 4.0 {
10.0
} else if y < center.1 - 4.0 {
-2.0
} else {
4.0
};
let content = fit_text(
&point.label,
available,
LABEL_SIZE,
metrics,
warnings,
&format!("/polar/points/{index}/label"),
);
let reach = metrics.width(&content, LABEL_SIZE);
let (from, to) = match anchor {
TextAnchor::Start => (x, x + reach),
TextAnchor::End => (x - reach, x),
TextAnchor::Middle => (x - reach / 2.0, x + reach / 2.0),
};
let bounds = (from, y + lift - 11.0, to, y + lift + 3.0);
if placed
.iter()
.any(|b| bounds.0 < b.2 && b.0 < bounds.2 && bounds.1 < b.3 && b.1 < bounds.3)
{
omitted = true;
continue;
}
placed.push(bounds);
elements.push(Element::Text(Text {
x,
y: y + lift,
class: "chartlet-label",
anchor,
content,
}));
}
if omitted {
warnings.push(ChartWarning::new(
"labels_omitted",
"/polar/points",
"some category names do not fit around the chart and were left out; every category remains in the data table",
));
}
Scene {
width: spec.width,
height: pixels(height),
elements,
}
}
pub(crate) fn description(spec: &ChartSpec) -> String {
let polar = polar(spec);
let style = spec.number_style();
let mut highest = 0;
let mut lowest = 0;
for (index, point) in polar.points.iter().enumerate() {
if point.value > polar.points[highest].value {
highest = index;
}
if point.value < polar.points[lowest].value {
lowest = index;
}
}
let named = |index: usize| {
(
polar.points[index].label.as_str(),
format_value(polar.points[index].value, style),
)
};
let (high, low) = (named(highest), named(lowest));
let labels: Vec<&str> = polar
.points
.iter()
.map(|point| point.label.as_str())
.collect();
text::polar_summary(
spec.locale,
(polar.points.len(), &labels.join(", ")),
((high.0, high.1.as_str()), (low.0, low.1.as_str())),
(&value_name(spec), &format_value(polar.end(), style)),
)
}
pub(crate) fn data_table(spec: &ChartSpec) -> DataTable {
let words = spec.locale.words();
let style = spec.number_style();
DataTable {
caption: format!("{} {}", words.data_for, spec.title),
columns: vec![words.category.to_owned(), value_name(spec)],
rows: polar(spec)
.points
.iter()
.map(|point| vec![point.label.clone(), format_value(point.value, style)])
.collect(),
}
}