use crate::coord::Coord;
use crate::render::backend::{DrawBackend, LineStyle, TextAnchor, TextStyle};
use crate::render::text::{axis_label_priority, label_bbox};
use crate::render::{Rect, RenderError};
use crate::scale::Scale;
use crate::theme::{AxisLabelFit, Theme};
fn along_x(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
let p = if coord.is_flipped() {
(0.0, pos)
} else {
(pos, 0.0)
};
coord.transform(p, area).0
}
fn along_y(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
let p = if coord.is_flipped() {
(pos, 0.0)
} else {
(0.0, pos)
};
coord.transform(p, area).1
}
pub fn draw_x_axis(
scale: &dyn Scale,
coord: &dyn Coord,
theme: &Theme,
plot_area: &Rect,
canvas: &Rect,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let breaks = scale.breaks();
let tick_len = theme.axis_ticks_length;
let axis_line = theme.get_axis_line_x();
let axis_ticks = theme.get_axis_ticks_x();
let top = scale.axis_position_opposite();
let edge_y = if top {
plot_area.y
} else {
plot_area.y + plot_area.height
};
let dir = if top { -1.0 } else { 1.0 };
if axis_line.visible {
backend.draw_line(
&[
(plot_area.x, edge_y),
(plot_area.x + plot_area.width, edge_y),
],
&LineStyle {
color: axis_line.color,
width: axis_line.width,
alpha: 1.0,
linetype: axis_line.linetype,
..Default::default()
},
)?;
}
let label_draw: Vec<Option<f64>> = if theme.axis_text_x.visible {
let angle = theme.axis_text_x.angle;
let rotated = angle.abs() > 10.0;
let anchor_frac = if rotated {
crate::render::text::rotated_anchor_frac(angle, dir)
} else {
0.5
};
let fit = theme.axis_text_x_fit;
let canvas_lo = canvas.x;
let canvas_hi = canvas.x + canvas.width;
const EDGE: f64 = 1.0;
let size = theme.axis_text_x.size;
let onscreen: Vec<(usize, f64, &str)> = breaks
.iter()
.enumerate()
.map(|(i, (pos, label))| (i, along_x(coord, *pos, plot_area), label.as_str()))
.filter(|(_, px, _)| (canvas_lo..=canvas_hi).contains(px))
.collect();
let mut keep = vec![true; onscreen.len()];
if theme.axis_text_x_check_overlap && onscreen.len() > 1 {
let n = onscreen.len();
let mut kept: Vec<(f64, f64, f64)> = Vec::with_capacity(n); keep = vec![false; n];
for p in axis_label_priority(n) {
let idx = p - 1; let (_, px, label) = onscreen[idx];
let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
let overlaps = kept.iter().any(|&(kx0, kx2, kpx)| {
if !rotated {
bbox[0] < kx2 && bbox[2] > kx0
} else {
(px - kpx).abs() * angle.to_radians().sin().abs() < size
}
});
if !overlaps {
keep[idx] = true;
kept.push((bbox[0], bbox[2], px));
}
}
}
let mut draw = vec![None; breaks.len()];
for (k, &(i, px, label)) in onscreen.iter().enumerate() {
if !keep[k] {
continue;
}
let lx = if fit == AxisLabelFit::Clip {
px
} else {
let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
let room_hi = canvas_hi - EDGE;
let room_lo = canvas_lo + EDGE;
if bbox[0] < room_lo {
px + (room_lo - bbox[0])
} else if bbox[2] > room_hi {
px - (bbox[2] - room_hi).min(bbox[0] - room_lo)
} else {
px
}
};
draw[i] = Some(lx);
}
draw
} else {
Vec::new()
};
let dodge = theme.axis_text_x_dodge.max(1);
let family = if theme.axis_text_x.family.is_empty() {
None
} else {
Some(theme.axis_text_x.family.clone())
};
let rotated = theme.axis_text_x.angle.abs() > 10.0;
let draw_anchor_frac = if rotated {
crate::render::text::rotated_anchor_frac(theme.axis_text_x.angle, dir)
} else {
0.5
};
let anchor = if !rotated {
TextAnchor::Middle
} else if draw_anchor_frac >= 1.0 {
TextAnchor::End
} else {
TextAnchor::Start
};
let rotation_margin = if rotated {
let (rise, fall) = crate::render::text::rotated_label_rise_fall(
breaks
.iter()
.filter(|(_, l)| !l.is_empty())
.map(|(_, l)| l.as_str()),
theme.axis_text_x.size,
theme.axis_text_x.angle,
draw_anchor_frac,
);
if dir > 0.0 {
rise
} else {
fall
}
} else {
0.0
};
for (i, (pos, label)) in breaks.iter().enumerate() {
let px = along_x(coord, *pos, plot_area);
if axis_ticks.visible {
backend.draw_line(
&[(px, edge_y), (px, edge_y + dir * tick_len)],
&LineStyle {
color: axis_ticks.color,
width: axis_ticks.width,
alpha: 1.0,
linetype: axis_ticks.linetype,
..Default::default()
},
)?;
}
if let Some(Some(lx)) = label_draw.get(i) {
let row_offset = (i % dodge) as f64 * (theme.axis_text_x.size + 2.0);
let ly = edge_y
+ dir
* (tick_len
+ theme.legend_spacing / 2.0
+ theme.axis_text_x.size / 2.0
+ row_offset
+ rotation_margin);
backend.draw_text(
label,
(*lx, ly),
&TextStyle {
color: theme.axis_text_x.color,
size: theme.axis_text_x.size,
anchor: anchor.clone(),
angle: theme.axis_text_x.angle,
family: family.clone(),
face: theme.axis_text_x.face,
},
)?;
}
}
if theme.axis_minor_ticks && axis_ticks.visible {
for pos in minor_breaks(&breaks) {
let px = along_x(coord, pos, plot_area);
backend.draw_line(
&[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
&LineStyle {
color: axis_ticks.color,
width: axis_ticks.width,
alpha: 1.0,
linetype: axis_ticks.linetype,
..Default::default()
},
)?;
}
}
let title = scale.name();
if !title.is_empty() && theme.axis_title_x.visible {
let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
let center_x = plot_area.x + hjust * plot_area.width;
let anchor = if hjust <= 0.02 {
TextAnchor::Start
} else if hjust >= 0.98 {
TextAnchor::End
} else {
TextAnchor::Middle
};
let title_y = edge_y
+ dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
let family = if theme.axis_title_x.family.is_empty() {
None
} else {
Some(theme.axis_title_x.family.clone())
};
backend.draw_text(
title,
(center_x, title_y),
&TextStyle {
color: theme.axis_title_x.color,
size: theme.axis_title_x.size,
anchor,
angle: 0.0,
family,
face: theme.axis_title_x.face,
},
)?;
}
Ok(())
}
pub fn draw_y_axis(
scale: &dyn Scale,
coord: &dyn Coord,
theme: &Theme,
plot_area: &Rect,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let breaks = scale.breaks();
let tick_len = theme.axis_ticks_length;
let axis_line = theme.get_axis_line_y();
let axis_ticks = theme.get_axis_ticks_y();
if axis_line.visible {
let top = (plot_area.x, plot_area.y);
let bottom = (plot_area.x, plot_area.y + plot_area.height);
backend.draw_line(
&[top, bottom],
&LineStyle {
color: axis_line.color,
width: axis_line.width,
alpha: 1.0,
linetype: axis_line.linetype,
..Default::default()
},
)?;
}
for (pos, label) in &breaks {
let py = along_y(coord, *pos, plot_area);
if axis_ticks.visible {
backend.draw_line(
&[(plot_area.x - tick_len, py), (plot_area.x, py)],
&LineStyle {
color: axis_ticks.color,
width: axis_ticks.width,
alpha: 1.0,
linetype: axis_ticks.linetype,
..Default::default()
},
)?;
}
if theme.axis_text_y.visible {
let family = if theme.axis_text_y.family.is_empty() {
None
} else {
Some(theme.axis_text_y.family.clone())
};
backend.draw_text(
label,
(plot_area.x - tick_len - theme.legend_spacing, py),
&TextStyle {
color: theme.axis_text_y.color,
size: theme.axis_text_y.size,
anchor: TextAnchor::End,
angle: theme.axis_text_y.angle,
family,
face: theme.axis_text_y.face,
},
)?;
}
}
if theme.axis_minor_ticks && axis_ticks.visible {
for pos in minor_breaks(&breaks) {
let py = along_y(coord, pos, plot_area);
backend.draw_line(
&[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
&LineStyle {
color: axis_ticks.color,
width: axis_ticks.width,
alpha: 1.0,
linetype: axis_ticks.linetype,
..Default::default()
},
)?;
}
}
let title = scale.name();
if !title.is_empty() && theme.axis_title_y.visible {
let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
let center_y = plot_area.y + plot_area.height / 2.0;
let family = if theme.axis_title_y.family.is_empty() {
None
} else {
Some(theme.axis_title_y.family.clone())
};
backend.draw_text(
title,
(title_x, center_y),
&TextStyle {
color: theme.axis_title_y.color,
size: theme.axis_title_y.size,
anchor: TextAnchor::Middle,
angle: 270.0,
family,
face: theme.axis_title_y.face,
},
)?;
}
Ok(())
}
pub fn draw_sec_y_axis(
primary_scale: &dyn Scale,
sec_axis: &crate::scale::sec_axis::SecAxis,
coord: &dyn Coord,
theme: &Theme,
plot_area: &Rect,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let breaks = primary_scale.breaks();
let tick_len = theme.axis_ticks_length;
let axis_line = theme.get_axis_line_y();
let axis_ticks = theme.get_axis_ticks_y();
let right_x = plot_area.x + plot_area.width;
if axis_line.visible {
backend.draw_line(
&[
(right_x, plot_area.y),
(right_x, plot_area.y + plot_area.height),
],
&LineStyle {
color: axis_line.color,
width: axis_line.width,
alpha: 1.0,
linetype: axis_line.linetype,
..Default::default()
},
)?;
}
for (pos, label) in &breaks {
let py = along_y(coord, *pos, plot_area);
if axis_ticks.visible {
backend.draw_line(
&[(right_x, py), (right_x + tick_len, py)],
&LineStyle {
color: axis_ticks.color,
width: axis_ticks.width,
alpha: 1.0,
linetype: axis_ticks.linetype,
..Default::default()
},
)?;
}
if theme.axis_text_y.visible {
let locale = primary_scale.locale();
let parsed = match locale {
Some(l) => l.parse_number(label),
None => label.parse::<f64>().ok(),
};
let sec_label = if let Some(v) = parsed {
let transformed = sec_axis.transform_value(v);
crate::scale::util::format_number_opt(transformed, locale)
} else {
label.clone()
};
let family = if theme.axis_text_y.family.is_empty() {
None
} else {
Some(theme.axis_text_y.family.clone())
};
backend.draw_text(
&sec_label,
(right_x + tick_len + theme.legend_spacing, py),
&TextStyle {
color: theme.axis_text_y.color,
size: theme.axis_text_y.size,
anchor: TextAnchor::Start,
angle: theme.axis_text_y.angle,
family,
face: theme.axis_text_y.face,
},
)?;
}
}
if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
let center_y = plot_area.y + plot_area.height / 2.0;
let family = if theme.axis_title_y.family.is_empty() {
None
} else {
Some(theme.axis_title_y.family.clone())
};
backend.draw_text(
&sec_axis.name,
(title_x, center_y),
&TextStyle {
color: theme.axis_title_y.color,
size: theme.axis_title_y.size,
anchor: TextAnchor::Middle,
angle: 90.0,
family,
face: theme.axis_title_y.face,
},
)?;
}
Ok(())
}
fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
if major.len() < 2 {
return vec![];
}
let mut minors = Vec::with_capacity(major.len() - 1);
for pair in major.windows(2) {
minors.push((pair[0].0 + pair[1].0) / 2.0);
}
minors
}
pub fn draw_gridlines(
x_scale: &dyn Scale,
y_scale: &dyn Scale,
coord: &dyn Coord,
theme: &Theme,
plot_area: &Rect,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let major_x = theme.get_panel_grid_major_x();
let major_y = theme.get_panel_grid_major_y();
let minor_x = theme.get_panel_grid_minor_x();
let minor_y = theme.get_panel_grid_minor_y();
let x_breaks = x_scale.breaks();
let y_breaks = y_scale.breaks();
if minor_x.visible {
for pos in minor_breaks(&x_breaks) {
let px = along_x(coord, pos, plot_area);
backend.draw_line(
&[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
&LineStyle {
color: minor_x.color,
width: minor_x.width,
alpha: 1.0,
linetype: minor_x.linetype,
..Default::default()
},
)?;
}
}
if minor_y.visible {
for pos in minor_breaks(&y_breaks) {
let py = along_y(coord, pos, plot_area);
backend.draw_line(
&[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
&LineStyle {
color: minor_y.color,
width: minor_y.width,
alpha: 1.0,
linetype: minor_y.linetype,
..Default::default()
},
)?;
}
}
if major_x.visible {
for (pos, _) in &x_breaks {
let px = along_x(coord, *pos, plot_area);
backend.draw_line(
&[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
&LineStyle {
color: major_x.color,
width: major_x.width,
alpha: 1.0,
linetype: major_x.linetype,
..Default::default()
},
)?;
}
}
if major_y.visible {
for (pos, _) in &y_breaks {
let py = along_y(coord, *pos, plot_area);
backend.draw_line(
&[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
&LineStyle {
color: major_y.color,
width: major_y.width,
alpha: 1.0,
linetype: major_y.linetype,
..Default::default()
},
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aes::Aesthetic;
use crate::coord::cartesian::CoordCartesian;
use crate::data::Value;
use crate::render::svg_backend::SvgBackend;
use crate::theme::presets::theme_gray;
struct FixedBreaksScale(Vec<(f64, String)>);
impl Scale for FixedBreaksScale {
fn aesthetic(&self) -> Aesthetic {
Aesthetic::X
}
fn train(&mut self, _values: &[Value]) {}
fn map(&self, _value: &Value) -> f64 {
0.0
}
fn breaks(&self) -> Vec<(f64, String)> {
self.0.clone()
}
fn name(&self) -> &str {
""
}
fn set_name(&mut self, _name: &str) {}
fn clone_box(&self) -> Box<dyn Scale> {
Box::new(FixedBreaksScale(self.0.clone()))
}
}
fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
SvgBackend::new(w, h, plot_area)
}
#[test]
fn a_tick_outside_the_svg_is_dropped() {
let scale = FixedBreaksScale(vec![
(0.2, "kept".to_string()),
(5.0, "off_canvas".to_string()),
]);
let coord = CoordCartesian::new();
let theme = theme_gray();
let plot_area = Rect {
x: 10.0,
y: 10.0,
width: 180.0,
height: 70.0,
};
let canvas = Rect {
x: 0.0,
y: 0.0,
width: 200.0,
height: 100.0,
};
let mut backend = backend(200, 100, plot_area.clone());
draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
let svg = backend.finish();
assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
assert!(
!svg.contains(">off_canvas<"),
"off-canvas label was drawn: {svg}"
);
}
#[test]
fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
let scale = FixedBreaksScale(vec![
(0.0, "a".to_string()),
(1.0, "2021-01-01".to_string()),
]);
let coord = CoordCartesian::new();
let mut theme = theme_gray();
theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
let plot_area = Rect {
x: 0.0,
y: 0.0,
width: 60.0,
height: 50.0,
};
let canvas = plot_area.clone();
let mut backend = backend(60, 50, plot_area.clone());
draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
let svg = backend.finish();
assert!(
svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
"clip mode should not shift the label: {svg}"
);
}
#[test]
fn shift_moves_the_same_label_inside_the_canvas() {
let scale = FixedBreaksScale(vec![
(0.0, "a".to_string()),
(1.0, "2021-01-01".to_string()),
]);
let coord = CoordCartesian::new();
let mut theme = theme_gray();
theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
let plot_area = Rect {
x: 0.0,
y: 0.0,
width: 60.0,
height: 50.0,
};
let canvas = plot_area.clone();
let mut backend = backend(60, 50, plot_area.clone());
draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
let svg = backend.finish();
assert!(
!svg.contains("x=\"60.00\" y=\""),
"shift mode should move the label off the bare tick: {svg}"
);
let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
let needle = ">2021-01-01<";
let tag_end = svg.find(needle).unwrap();
let tag_start = svg[..tag_end].rfind("<text ").unwrap();
let tag = &svg[tag_start..tag_end];
let x_attr = tag.rsplit("x=\"").next().unwrap();
let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
assert!(
x + w / 2.0 <= 60.0 - 1.0 + 0.01,
"right edge still overflows: x={x} w={w}"
);
}
}