use crate::brush::Brush;
use crate::geometry::{Affine, Point, Rect, Vec2};
use crate::layout::{Measure, WidthHint};
use crate::pick::PickId;
use crate::plot::chrome::linear_axis::{
draw_axis_label, draw_linear_axis_at, AxisChromeStyle, AxisLabelAt,
};
use crate::plot::projection::PolarProjection;
use crate::plot::scale::Scale;
use crate::plot::theme::{HAlign, Theme};
use crate::primitives::{segment, PolylineSampler};
use crate::scales::breaks::DEFAULT_BREAK_COUNT;
use crate::scales::chrome::AxisSide;
use crate::scales::value::Value;
use crate::scene::SceneBuilder;
use crate::scene::{Glyph, GlyphRun};
use crate::text::run_layout_glyphs;
use crate::text::{TextRun, TextStyle};
#[allow(clippy::too_many_arguments)]
pub fn draw_radius_axis(
scene: &mut dyn SceneBuilder,
panel: Rect,
polar: &PolarProjection,
scale: &Scale,
theta_frac: f64,
dpi: f64,
title: Option<&str>,
theme: &Theme,
) {
let g = polar.geometry(panel);
if g.r_outer <= 0.0 {
return;
}
let majors: Vec<(f64, String)> = scale
.breaks(DEFAULT_BREAK_COUNT)
.iter()
.filter(|v| !matches!(v, Value::Null))
.filter_map(|v| {
scale
.map_break(v)
.as_number()
.map(|f| (f, scale.format(v, &theme.locale)))
})
.filter(|(f, _)| f.is_finite())
.collect();
let minors: Vec<f64> = scale
.minor_breaks(DEFAULT_BREAK_COUNT)
.into_iter()
.filter(|v| !matches!(v, Value::Null))
.filter_map(|v| scale.map_break(&v).as_number())
.filter(|f| f.is_finite())
.collect();
let (ux, uy) = polar.unit_position(theta_frac);
let start = Point::new(g.cx + g.r_inner * ux, g.cy - g.r_inner * uy);
let end = Point::new(g.cx + g.r_outer * ux, g.cy - g.r_outer * uy);
let tick_direction = radius_axis_tick_direction(polar, theta_frac);
let resolved = theme.resolved_axis(1, 0);
let style = AxisChromeStyle::from_resolved(
&resolved,
&theme.palette,
dpi,
crate::plot::chrome::root_text_pt(theme),
);
draw_linear_axis_at(
scene,
start,
end,
tick_direction,
&majors,
&minors,
&style,
dpi,
);
if let Some(title_text) = title {
if let Some(title_el) = resolved.title.as_ref() {
let label_style = style.text_style.clone();
let (max_label_w, max_label_h) =
majors
.iter()
.fold((0.0_f64, 0.0_f64), |(mw, mh), (_, label)| {
let run = TextRun::new(label, &label_style, dpi);
let h = run.set_max_width(f32::INFINITY, HAlign::Start) as f64;
let w = run.natural_width();
(mw.max(w), mh.max(h))
});
let (tx, ty) = tick_direction;
let label_extent = max_label_w * tx.abs() + max_label_h * ty.abs();
draw_radius_title(
scene,
panel,
polar,
theta_frac,
label_extent,
title_text,
title_el,
&theme.palette,
style.tick_length_px,
style.gap_px,
style.title_gap_px,
dpi,
crate::plot::chrome::root_text_pt(theme),
);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn draw_angular_axis(
scene: &mut dyn SceneBuilder,
panel: Rect,
polar: &PolarProjection,
scale: &Scale,
ring: AngularRing,
dpi: f64,
title: Option<&str>,
theme: &Theme,
) {
let g = polar.geometry(panel);
if g.r_outer <= 0.0 {
return;
}
let (ring_r, tick_sign) = match ring {
AngularRing::Outer => (g.r_outer, 1.0_f64),
AngularRing::Inner => {
if g.r_inner <= 0.0 {
return;
}
(g.r_inner, -1.0)
}
};
let span = polar.theta_end() - polar.theta_start();
let is_full_circle = (span.abs() - std::f64::consts::TAU).abs() < 1e-6;
let side_idx = match ring {
AngularRing::Outer => 0_u8,
AngularRing::Inner => 1_u8,
};
let resolved = theme.resolved_axis(0, side_idx);
let chrome_style = AxisChromeStyle::from_resolved(
&resolved,
&theme.palette,
dpi,
crate::plot::chrome::root_text_pt(theme),
);
let tick_px = chrome_style.tick_length_px;
let minor_tick_px = chrome_style.minor_tick_length_px;
let label_gap_px = chrome_style.gap_px;
let style = chrome_style.text_style.clone();
for v in scale.minor_breaks(DEFAULT_BREAK_COUNT) {
if matches!(v, Value::Null) {
continue;
}
let theta_frac = match scale.map_break(&v).as_number() {
Some(f) if f.is_finite() => f,
_ => continue,
};
if !(0.0..=1.0).contains(&theta_frac) {
continue;
}
if is_full_circle && theta_frac >= 1.0 - 1e-9 {
continue;
}
let theta = polar.theta_for_frac(theta_frac);
let on_ring = PolarProjection::polar_point(Point::new(g.cx, g.cy), ring_r, theta);
let (rx, ry) = (tick_sign * theta.cos(), -tick_sign * theta.sin());
let tick_end = Point::new(
on_ring.x + minor_tick_px * rx,
on_ring.y + minor_tick_px * ry,
);
if let Some(minor_brush) = chrome_style.minor_brush.as_ref() {
scene.stroke(
&chrome_style.minor_stroke,
Affine::IDENTITY,
minor_brush,
None,
&segment(on_ring, tick_end),
PickId::Skip,
);
}
}
for v in &scale.breaks(DEFAULT_BREAK_COUNT) {
if matches!(v, Value::Null) {
continue;
}
let theta_frac = match scale.map_break(v).as_number() {
Some(f) if f.is_finite() => f,
_ => continue,
};
if !(0.0..=1.0).contains(&theta_frac) {
continue;
}
if is_full_circle && theta_frac >= 1.0 - 1e-9 {
continue;
}
let theta = polar.theta_for_frac(theta_frac);
let on_ring = PolarProjection::polar_point(Point::new(g.cx, g.cy), ring_r, theta);
let (rx, ry) = (tick_sign * theta.cos(), -tick_sign * theta.sin());
let tick_end = Point::new(on_ring.x + tick_px * rx, on_ring.y + tick_px * ry);
if let (Some(tick_brush), tick_stroke) =
(chrome_style.tick_brush.as_ref(), &chrome_style.tick_stroke)
{
scene.stroke(
tick_stroke,
Affine::IDENTITY,
tick_brush,
None,
&segment(on_ring, tick_end),
PickId::Skip,
);
}
let anchor = Point::new(
tick_end.x + label_gap_px * rx,
tick_end.y + label_gap_px * ry,
);
let text = scale.format(v, &theme.locale);
draw_axis_label(
scene,
&text,
&style,
&chrome_style.text_brush,
chrome_style.text_outline.as_ref(),
AxisLabelAt {
anchor,
direction: (rx, ry),
},
dpi,
);
}
if let Some(title_text) = title {
if let Some(title_el) = resolved.title.as_ref() {
let (max_label_w, max_label_h) = scale
.breaks(DEFAULT_BREAK_COUNT)
.iter()
.filter(|v| !matches!(v, Value::Null))
.fold((0.0_f64, 0.0_f64), |(mw, mh), v| {
let label = scale.format(v, &theme.locale);
let run = TextRun::new(&label, &style, dpi);
let h = run.set_max_width(f32::INFINITY, HAlign::Start) as f64;
let w = run.natural_width();
(mw.max(w), mh.max(h))
});
let label_max = max_label_w.max(max_label_h);
match ring {
AngularRing::Outer => {
draw_angular_title(
scene,
panel,
polar,
label_max,
title_text,
title_el,
&theme.palette,
chrome_style.tick_length_px,
chrome_style.gap_px,
chrome_style.title_gap_px,
dpi,
crate::plot::chrome::root_text_pt(theme),
);
}
AngularRing::Inner => {
}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AngularRing {
Outer,
Inner,
}
#[derive(Clone, Debug)]
pub(crate) struct PolarBleed {
pub top_px: f64,
pub right_px: f64,
pub bottom_px: f64,
pub left_px: f64,
}
impl PolarBleed {
pub fn on(&self, side: AxisSide) -> f64 {
match side {
AxisSide::Top => self.top_px,
AxisSide::Right => self.right_px,
AxisSide::Bottom => self.bottom_px,
AxisSide::Left => self.left_px,
}
}
}
pub(crate) struct PolarBleedMeasure {
pub side: AxisSide,
pub bleed: PolarBleed,
}
impl Measure for PolarBleedMeasure {
fn width_hint(&self, _dpi: f64) -> WidthHint {
if self.side.is_vertical() {
WidthHint::Min(self.bleed.on(self.side))
} else {
WidthHint::Min(0.0)
}
}
fn height_at(&self, _width: f64, _dpi: f64) -> f64 {
if self.side.is_horizontal() {
self.bleed.on(self.side)
} else {
0.0
}
}
}
pub(crate) fn compute_polar_bleed(axes: &[BleedAxis], dpi: f64, theme: &Theme) -> PolarBleed {
let root_pt = crate::plot::chrome::root_text_pt(theme);
let angular_style =
AxisChromeStyle::from_resolved(&theme.resolved_axis(0, 0), &theme.palette, dpi, root_pt);
let radial_style =
AxisChromeStyle::from_resolved(&theme.resolved_axis(1, 0), &theme.palette, dpi, root_pt);
let title_style_for = |kind: &BleedLabelKind| match kind {
BleedLabelKind::Radius => &radial_style,
_ => &angular_style,
};
let mut bleed = PolarBleed {
top_px: 0.0,
right_px: 0.0,
bottom_px: 0.0,
left_px: 0.0,
};
const CARDINAL_EPS: f64 = 0.05;
for axis in axes {
for label in &axis.labels {
let label_style = title_style_for(&label.kind);
let run = TextRun::new(&label.text, &label_style.text_style, dpi);
let h = run.set_max_width(f32::INFINITY, HAlign::Start) as f64;
let w = run.natural_width();
let anchor_offset = match label.kind {
BleedLabelKind::OuterAngular | BleedLabelKind::Radius => {
label_style.tick_length_px + label_style.gap_px
}
BleedLabelKind::InnerAngular => continue,
};
let (dx, dy) = label.direction;
let dx_q = if dx > CARDINAL_EPS {
1.0
} else if dx < -CARDINAL_EPS {
-1.0
} else {
0.0
};
let dy_q = if dy > CARDINAL_EPS {
1.0
} else if dy < -CARDINAL_EPS {
-1.0
} else {
0.0
};
let b_right = dx * anchor_offset + (dx_q + 1.0) * w * 0.5;
if b_right > 0.0 {
bleed.right_px = bleed.right_px.max(b_right);
}
let b_left = -dx * anchor_offset + (1.0 - dx_q) * w * 0.5;
if b_left > 0.0 {
bleed.left_px = bleed.left_px.max(b_left);
}
let b_bottom = dy * anchor_offset + (dy_q + 1.0) * h * 0.5;
if b_bottom > 0.0 {
bleed.bottom_px = bleed.bottom_px.max(b_bottom);
}
let b_top = -dy * anchor_offset + (1.0 - dy_q) * h * 0.5;
if b_top > 0.0 {
bleed.top_px = bleed.top_px.max(b_top);
}
}
if let Some(title) = &axis.title {
match title.kind {
BleedTitleKind::OuterAngular {
direction: (dx, dy),
label_max_px,
} => {
let Some(title_text_style) = angular_title_text_style(
&theme.resolved_axis(0, 0),
crate::plot::chrome::root_text_pt(theme),
) else {
continue;
};
let run = TextRun::new(&title.text, &title_text_style, dpi);
let title_h = run.set_max_width(f32::INFINITY, HAlign::Start) as f64;
let title_w = run.natural_width();
let radial = angular_style.tick_length_px
+ angular_style.gap_px
+ label_max_px
+ angular_style.title_gap_px
+ title_h;
if dx > 0.0 {
bleed.right_px = bleed.right_px.max(dx * radial);
}
if dx < 0.0 {
bleed.left_px = bleed.left_px.max(-dx * radial);
}
if dy > 0.0 {
bleed.bottom_px = bleed.bottom_px.max(dy * radial);
}
if dy < 0.0 {
bleed.top_px = bleed.top_px.max(-dy * radial);
}
let tangential = title_w * 0.5;
if dx.abs() > dy.abs() {
bleed.top_px = bleed.top_px.max(tangential);
bleed.bottom_px = bleed.bottom_px.max(tangential);
} else {
bleed.left_px = bleed.left_px.max(tangential);
bleed.right_px = bleed.right_px.max(tangential);
}
}
}
}
}
bleed
}
pub(crate) struct BleedAxis {
pub labels: Vec<BleedLabel>,
pub title: Option<BleedTitle>,
}
pub(crate) struct BleedTitle {
pub text: String,
pub kind: BleedTitleKind,
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum BleedTitleKind {
OuterAngular {
direction: (f64, f64),
label_max_px: f64,
},
}
pub(crate) struct BleedLabel {
pub text: String,
pub kind: BleedLabelKind,
pub direction: (f64, f64),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum BleedLabelKind {
OuterAngular,
InnerAngular,
Radius,
}
fn radius_axis_tick_direction(polar: &PolarProjection, theta_frac: f64) -> (f64, f64) {
let sign = if polar.theta_end() > polar.theta_start() {
1.0
} else {
-1.0
};
let theta = polar.theta_for_frac(theta_frac);
(sign * theta.sin(), sign * theta.cos())
}
const ANGULAR_TITLE_ARC_SEGMENTS: usize = 32;
fn angular_title_text_style(
resolved: &crate::plot::theme::ResolvedAxis,
root_pt: f64,
) -> Option<TextStyle> {
resolved
.title
.as_ref()
.map(|el| crate::plot::chrome::text::text_style_from(el, root_pt))
}
#[allow(clippy::too_many_arguments)]
fn draw_radius_title(
scene: &mut dyn SceneBuilder,
panel: Rect,
polar: &PolarProjection,
theta_frac: f64,
label_extent_px: f64,
title: &str,
title_el: &crate::plot::theme::TextElement,
palette: &crate::plot::theme::Palette,
tick_px: f64,
label_gap_px: f64,
title_gap_px: f64,
dpi: f64,
root_pt: f64,
) {
let g = polar.geometry(panel);
if g.r_outer <= 0.0 {
return;
}
let (ux, uy) = polar.unit_position(theta_frac);
let (sx, sy) = (ux, -uy);
let (tx, ty) = radius_axis_tick_direction(polar, theta_frac);
let mut theta_spoke = sy.atan2(sx);
let style = crate::plot::chrome::text::text_style_from(title_el, root_pt);
let run = crate::text::TextRun::new(title, &style, dpi);
let title_w = run.natural_width();
let title_h = run.set_max_width(f32::INFINITY, HAlign::Start) as f64;
let glyphs = run_layout_glyphs(&run);
if glyphs.is_empty() {
return;
}
let baseline_ref = glyphs[0].y as f64;
let perp_distance = tick_px + label_gap_px + label_extent_px + title_gap_px + title_h * 0.5;
let mid_r = (g.r_inner + g.r_outer) * 0.5;
let body_center_x = g.cx + mid_r * ux + perp_distance * tx;
let body_center_y = g.cy - mid_r * uy + perp_distance * ty;
let anchor_x = body_center_x - (title_w * 0.5) * sx;
let anchor_y = body_center_y - (title_w * 0.5) * sy;
let upside_down = theta_spoke.sin() > 0.0;
let (origin_x, origin_y) = if upside_down {
theta_spoke += std::f64::consts::PI;
let cos_t = theta_spoke.cos();
let sin_t = theta_spoke.sin();
(anchor_x - title_w * cos_t, anchor_y - title_w * sin_t)
} else {
(anchor_x, anchor_y)
};
let perp_offset = -title_h * 0.5 + baseline_ref;
let title_color = title_el
.color
.clone()
.or_else(|| crate::plot::theme::text_concrete_defaults().color)
.expect("text_concrete_defaults sets color")
.resolve(palette);
let brush = Brush::Solid(title_color);
let outline = crate::plot::chrome::text::text_outline_from(title_el, palette, dpi);
for g_glyph in &glyphs {
let y_above_baseline = g_glyph.y as f64 - baseline_ref;
let xform = Affine::translate(Vec2::new(origin_x, origin_y))
* Affine::rotate(theta_spoke)
* Affine::translate(Vec2::new(g_glyph.x as f64, perp_offset + y_above_baseline));
let stamp = Glyph {
id: g_glyph.id,
x: 0.0,
y: 0.0,
};
if let Some(o) = &outline {
let stroke_run = GlyphRun {
font: &g_glyph.font,
font_size: g_glyph.font_size,
transform: xform,
glyph_transform: None,
brush: &o.brush,
brush_alpha: 1.0,
hint: false,
glyphs: std::slice::from_ref(&stamp),
style: Some(&o.stroke),
};
scene.draw_glyphs(&stroke_run, PickId::Skip);
}
let glyph_run = GlyphRun {
font: &g_glyph.font,
font_size: g_glyph.font_size,
transform: xform,
glyph_transform: None,
brush: &brush,
brush_alpha: 1.0,
hint: false,
glyphs: std::slice::from_ref(&stamp),
style: None,
};
scene.draw_glyphs(&glyph_run, PickId::Skip);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_angular_title(
scene: &mut dyn SceneBuilder,
panel: Rect,
polar: &PolarProjection,
label_max_px: f64,
title: &str,
title_el: &crate::plot::theme::TextElement,
palette: &crate::plot::theme::Palette,
tick_px: f64,
label_gap_px: f64,
title_gap_px: f64,
dpi: f64,
root_pt: f64,
) {
let g = polar.geometry(panel);
if g.r_outer <= 0.0 {
return;
}
let style = crate::plot::chrome::text::text_style_from(title_el, root_pt);
let run = crate::text::TextRun::new(title, &style, dpi);
let text_w = run.natural_width();
let _title_h = run.set_max_width(f32::INFINITY, HAlign::Start) as f64;
let glyphs = run_layout_glyphs(&run);
if glyphs.is_empty() || text_w <= 0.0 {
return;
}
let baseline_ref = glyphs[0].y as f64;
let descent_px = run.last_line_descender();
let ascent_px = run.natural_height() - descent_px;
let r_title = g.r_outer + tick_px + label_gap_px + label_max_px + title_gap_px;
if r_title <= 0.0 {
return;
}
let span = polar.theta_end() - polar.theta_start();
let is_full_circle = (span.abs() - std::f64::consts::TAU).abs() < 1e-6;
let theta_mid_math = if is_full_circle {
std::f64::consts::FRAC_PI_2 } else {
(polar.theta_start() + polar.theta_end()) * 0.5
};
let arc_radians = text_w / r_title;
if !arc_radians.is_finite() || arc_radians <= 0.0 {
return;
}
let sweep_sign = if polar.theta_end() > polar.theta_start() {
-1.0
} else {
1.0
};
let start_math = theta_mid_math - sweep_sign * arc_radians * 0.5;
let end_math = theta_mid_math + sweep_sign * arc_radians * 0.5;
let n = ANGULAR_TITLE_ARC_SEGMENTS;
let mut points: Vec<Point> = Vec::with_capacity(n + 1);
for i in 0..=n {
let t = i as f64 / n as f64;
let theta = start_math + (end_math - start_math) * t;
points.push(Point::new(
g.cx + r_title * theta.cos(),
g.cy - r_title * theta.sin(),
));
}
let sampler = PolylineSampler::from_polyline(&points);
let path_length = sampler.total_length();
if path_length <= 0.0 {
return;
}
let natural_shift = (path_length - text_w) * 0.5; let mut upside_down = 0usize;
let mut counted = 0usize;
for gph in &glyphs {
let half_advance = gph.advance as f64 * 0.5;
let d = natural_shift + gph.x as f64 + half_advance;
if !d.is_finite() {
continue;
}
let d_clamped = d.clamp(0.0, path_length);
if let Some(s) = sampler.sample_at(d_clamped) {
counted += 1;
if s.tangent.x < 0.0 {
upside_down += 1;
}
}
}
let flipped = counted > 0 && upside_down * 2 > counted;
let hjust_shift = if flipped {
natural_shift
} else {
natural_shift
};
let _ = ascent_px;
let _ = descent_px;
let effective_vjust = 0.0;
let title_color = title_el
.color
.clone()
.or_else(|| crate::plot::theme::text_concrete_defaults().color)
.expect("text_concrete_defaults sets color")
.resolve(palette);
let brush = Brush::Solid(title_color);
let outline = crate::plot::chrome::text::text_outline_from(title_el, palette, dpi);
for gph in &glyphs {
let half_advance = gph.advance as f64 * 0.5;
let d_glyph = hjust_shift + gph.x as f64 + half_advance;
if !d_glyph.is_finite() || d_glyph < 0.0 || d_glyph > path_length {
continue;
}
let d_sample = if flipped {
path_length - d_glyph
} else {
d_glyph
};
let sample = match sampler.sample_at(d_sample) {
Some(s) => s,
None => continue,
};
let tangent = if flipped {
-sample.tangent
} else {
sample.tangent
};
let theta = tangent.y.atan2(tangent.x);
let y_above_baseline = gph.y as f64 - baseline_ref;
let xform = Affine::translate(Vec2::new(sample.point.x, sample.point.y))
* Affine::rotate(theta)
* Affine::translate(Vec2::new(-half_advance, effective_vjust + y_above_baseline));
let stamp = Glyph {
id: gph.id,
x: 0.0,
y: 0.0,
};
if let Some(o) = &outline {
let stroke_run = GlyphRun {
font: &gph.font,
font_size: gph.font_size,
transform: xform,
glyph_transform: None,
brush: &o.brush,
brush_alpha: 1.0,
hint: false,
glyphs: std::slice::from_ref(&stamp),
style: Some(&o.stroke),
};
scene.draw_glyphs(&stroke_run, PickId::Skip);
}
let glyph_run = GlyphRun {
font: &gph.font,
font_size: gph.font_size,
transform: xform,
glyph_transform: None,
brush: &brush,
brush_alpha: 1.0,
hint: false,
glyphs: std::slice::from_ref(&stamp),
style: None,
};
scene.draw_glyphs(&glyph_run, PickId::Skip);
}
}
#[cfg(test)]
mod tests {
use super::*;
const DPI: f64 = 96.0;
fn east_label(text: &str) -> BleedAxis {
BleedAxis {
labels: vec![BleedLabel {
text: text.to_string(),
kind: BleedLabelKind::OuterAngular,
direction: (1.0, 0.0),
}],
title: None,
}
}
#[test]
fn bleed_covers_a_multi_word_label_in_full() {
let theme = Theme::default();
let whole = compute_polar_bleed(&[east_label("Species: setosa")], DPI, &theme);
let first_word = compute_polar_bleed(&[east_label("Species:")], DPI, &theme);
assert!(
whole.right_px > first_word.right_px + 1.0,
"the reservation has to grow past the label's widest word: \
whole={}, first word={}",
whole.right_px,
first_word.right_px
);
}
}