use crate::core::{Affine2D, Bbox, Path, color::Rgba};
use crate::mathtext::{RichText, layout_rich_text};
use crate::render::{GraphicsContext, Renderer};
use crate::text::FontSource;
use crate::axis::scale::{LinearScale, Scale};
use crate::axis::ticker::{AutoLocator, Formatter, Locator, ScalarFormatter};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisSide {
Bottom,
Top,
Left,
Right,
}
impl AxisSide {
fn is_horizontal(self) -> bool {
matches!(self, AxisSide::Bottom | AxisSide::Top)
}
}
pub struct Axis {
side: AxisSide,
scale: Box<dyn Scale>,
locator: Box<dyn Locator>,
formatter: Box<dyn Formatter>,
label: Option<String>,
color: Rgba,
tick_length: f64,
tick_width: f64,
tick_label_size: f64,
axis_label_size: f64,
tick_label_pad: f64,
axis_label_pad: f64,
grid: bool,
}
impl Axis {
#[must_use]
pub fn new(side: AxisSide) -> Self {
Axis {
side,
scale: Box::new(LinearScale::new()),
locator: Box::new(AutoLocator::new()),
formatter: Box::new(ScalarFormatter::new()),
label: None,
color: Rgba::BLACK,
tick_length: 3.5,
tick_width: 1.0,
tick_label_size: 10.0,
axis_label_size: 10.0,
tick_label_pad: 3.5,
axis_label_pad: 4.0,
grid: false,
}
}
#[must_use]
pub fn with_scale(mut self, scale: Box<dyn Scale>) -> Self {
self.scale = scale;
self
}
pub fn set_scale(&mut self, scale: Box<dyn Scale>) -> &mut Self {
self.scale = scale;
self
}
#[must_use]
pub fn with_locator(mut self, locator: Box<dyn Locator>) -> Self {
self.locator = locator;
self
}
pub fn set_locator(&mut self, locator: Box<dyn Locator>) -> &mut Self {
self.locator = locator;
self
}
#[must_use]
pub fn with_formatter(mut self, formatter: Box<dyn Formatter>) -> Self {
self.formatter = formatter;
self
}
pub fn set_formatter(&mut self, formatter: Box<dyn Formatter>) -> &mut Self {
self.formatter = formatter;
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn with_color(mut self, color: Rgba) -> Self {
self.color = color;
self
}
#[must_use]
pub fn with_tick_length(mut self, tick_length: f64) -> Self {
self.tick_length = tick_length;
self
}
#[must_use]
pub fn with_tick_width(mut self, tick_width: f64) -> Self {
self.tick_width = tick_width;
self
}
#[must_use]
pub fn with_label_size(mut self, label_size: f64) -> Self {
self.tick_label_size = label_size;
self.axis_label_size = label_size;
self
}
#[must_use]
pub fn with_tick_label_size(mut self, tick_label_size: f64) -> Self {
self.tick_label_size = tick_label_size;
self
}
#[must_use]
pub fn with_axis_label_size(mut self, axis_label_size: f64) -> Self {
self.axis_label_size = axis_label_size;
self
}
#[must_use]
pub fn with_label_pad(mut self, label_pad: f64) -> Self {
self.tick_label_pad = label_pad;
self.axis_label_pad = label_pad;
self
}
#[must_use]
pub fn with_tick_label_pad(mut self, tick_label_pad: f64) -> Self {
self.tick_label_pad = tick_label_pad;
self
}
#[must_use]
pub fn with_axis_label_pad(mut self, axis_label_pad: f64) -> Self {
self.axis_label_pad = axis_label_pad;
self
}
#[must_use]
pub fn with_grid(mut self, grid: bool) -> Self {
self.grid = grid;
self
}
fn data_to_pixel(&self, v: f64, axes_bbox: &Bbox, vmin: f64, vmax: f64) -> f64 {
let tmin = self.scale.transform(vmin);
let tmax = self.scale.transform(vmax);
let denom = tmax - tmin;
let frac = if denom == 0.0 {
0.0
} else {
(self.scale.transform(v) - tmin) / denom
};
let (lo, hi) = if self.side.is_horizontal() {
(axes_bbox.xmin(), axes_bbox.xmax())
} else {
(axes_bbox.ymin(), axes_bbox.ymax())
};
lo + frac * (hi - lo)
}
pub fn draw(
&self,
renderer: &mut dyn Renderer,
axes_bbox: &Bbox,
data_lim: (f64, f64),
font: &FontSource,
) {
let (vmin, vmax) = data_lim;
let (lo_v, hi_v) = if vmin <= vmax {
(vmin, vmax)
} else {
(vmax, vmin)
};
let ticks: Vec<f64> = self
.locator
.tick_values(vmin, vmax)
.into_iter()
.filter(|&t| t >= lo_v && t <= hi_v)
.collect();
let stroke_gc = GraphicsContext::new()
.with_stroke(self.color)
.with_line_width(self.tick_width.max(1.0));
if self.grid {
self.draw_grid(renderer, axes_bbox, &ticks, vmin, vmax);
}
self.draw_spine(renderer, axes_bbox, &stroke_gc);
self.draw_ticks(renderer, axes_bbox, &ticks, vmin, vmax, &stroke_gc);
self.draw_tick_labels(renderer, axes_bbox, &ticks, vmin, vmax, font);
self.draw_axis_label(renderer, axes_bbox, &ticks, font);
}
fn draw_spine(&self, renderer: &mut dyn Renderer, axes_bbox: &Bbox, gc: &GraphicsContext) {
let (xmin, xmax) = (axes_bbox.xmin(), axes_bbox.xmax());
let (ymin, ymax) = (axes_bbox.ymin(), axes_bbox.ymax());
let line = match self.side {
AxisSide::Bottom => [[xmin, ymin], [xmax, ymin]],
AxisSide::Top => [[xmin, ymax], [xmax, ymax]],
AxisSide::Left => [[xmin, ymin], [xmin, ymax]],
AxisSide::Right => [[xmax, ymin], [xmax, ymax]],
};
let path = Path::from_polyline(&line);
renderer.draw_path(gc, &path, &Affine2D::identity(), None);
}
fn draw_grid(
&self,
renderer: &mut dyn Renderer,
axes_bbox: &Bbox,
ticks: &[f64],
vmin: f64,
vmax: f64,
) {
let gc = GraphicsContext::new()
.with_stroke(Rgba::rgb(0.85, 0.85, 0.85))
.with_line_width(self.tick_width.max(1.0));
let (xmin, xmax) = (axes_bbox.xmin(), axes_bbox.xmax());
let (ymin, ymax) = (axes_bbox.ymin(), axes_bbox.ymax());
for &t in ticks {
let p = self.data_to_pixel(t, axes_bbox, vmin, vmax);
let line = if self.side.is_horizontal() {
[[p, ymin], [p, ymax]]
} else {
[[xmin, p], [xmax, p]]
};
let path = Path::from_polyline(&line);
renderer.draw_path(&gc, &path, &Affine2D::identity(), None);
}
}
fn draw_ticks(
&self,
renderer: &mut dyn Renderer,
axes_bbox: &Bbox,
ticks: &[f64],
vmin: f64,
vmax: f64,
gc: &GraphicsContext,
) {
let len = self.tick_length;
for &t in ticks {
let p = self.data_to_pixel(t, axes_bbox, vmin, vmax);
let line = match self.side {
AxisSide::Bottom => [[p, axes_bbox.ymin()], [p, axes_bbox.ymin() - len]],
AxisSide::Top => [[p, axes_bbox.ymax()], [p, axes_bbox.ymax() + len]],
AxisSide::Left => [[axes_bbox.xmin(), p], [axes_bbox.xmin() - len, p]],
AxisSide::Right => [[axes_bbox.xmax(), p], [axes_bbox.xmax() + len, p]],
};
let path = Path::from_polyline(&line);
renderer.draw_path(gc, &path, &Affine2D::identity(), None);
}
}
fn draw_tick_labels(
&self,
renderer: &mut dyn Renderer,
axes_bbox: &Bbox,
ticks: &[f64],
vmin: f64,
vmax: f64,
font: &FontSource,
) {
let gc = GraphicsContext::new();
let labels = self.formatter.format_ticks(ticks);
for (&t, text) in ticks.iter().zip(labels.iter()) {
if text.is_empty() {
continue;
}
let rich = layout_rich_text(font, text, self.tick_label_size);
let p = self.data_to_pixel(t, axes_bbox, vmin, vmax);
let origin = self.tick_label_origin(axes_bbox, p, &rich);
let shift = Affine2D::from_translation(origin[0], origin[1]);
for path in &rich.paths {
renderer.draw_path(
&gc,
&path.transformed(&shift),
&Affine2D::identity(),
Some(self.color),
);
}
}
}
fn tick_label_origin(&self, axes_bbox: &Bbox, p: f64, rich: &RichText) -> [f64; 2] {
match self.side {
AxisSide::Bottom => {
let x = p - rich.width / 2.0;
let y = axes_bbox.ymin() - self.tick_length - self.tick_label_pad - rich.ascent;
[x, y]
}
AxisSide::Top => {
let x = p - rich.width / 2.0;
let y = axes_bbox.ymax() + self.tick_length + self.tick_label_pad + rich.descent;
[x, y]
}
AxisSide::Left => {
let x = axes_bbox.xmin() - self.tick_length - self.tick_label_pad - rich.width;
let y = p - (rich.ascent - rich.descent) / 2.0;
[x, y]
}
AxisSide::Right => {
let x = axes_bbox.xmax() + self.tick_length + self.tick_label_pad;
let y = p - (rich.ascent - rich.descent) / 2.0;
[x, y]
}
}
}
fn tick_label_band_extent(&self, ticks: &[f64], font: &FontSource) -> f64 {
self.formatter
.format_ticks(ticks)
.iter()
.filter(|label| !label.is_empty())
.map(|label| {
let rich = layout_rich_text(font, label, self.tick_label_size);
if self.side.is_horizontal() {
rich.ascent + rich.descent
} else {
rich.width
}
})
.fold(0.0, f64::max)
}
fn draw_axis_label(
&self,
renderer: &mut dyn Renderer,
axes_bbox: &Bbox,
ticks: &[f64],
font: &FontSource,
) {
let Some(label) = &self.label else {
return;
};
if label.is_empty() {
return;
}
let gc = GraphicsContext::new();
let rich = layout_rich_text(font, label, self.axis_label_size);
let tick_label_extent = self.tick_label_band_extent(ticks, font);
let label_offset =
self.tick_length + self.tick_label_pad + tick_label_extent + self.axis_label_pad;
let transform = match self.side {
AxisSide::Bottom => {
let x = (axes_bbox.xmin() + axes_bbox.xmax()) / 2.0 - rich.width / 2.0;
let y = axes_bbox.ymin() - label_offset - rich.ascent;
Affine2D::from_translation(x, y)
}
AxisSide::Top => {
let x = (axes_bbox.xmin() + axes_bbox.xmax()) / 2.0 - rich.width / 2.0;
let y = axes_bbox.ymax() + label_offset + rich.descent;
Affine2D::from_translation(x, y)
}
AxisSide::Left => {
let right = axes_bbox.xmin() - label_offset;
let x = right - rich.descent;
let y = (axes_bbox.ymin() + axes_bbox.ymax()) / 2.0 - rich.width / 2.0;
Affine2D::from_rotation_deg(90.0).then(&Affine2D::from_translation(x, y))
}
AxisSide::Right => {
let left = axes_bbox.xmax() + label_offset;
let x = left + rich.descent;
let y = (axes_bbox.ymin() + axes_bbox.ymax()) / 2.0 + rich.width / 2.0;
Affine2D::from_rotation_deg(-90.0).then(&Affine2D::from_translation(x, y))
}
};
for path in &rich.paths {
renderer.draw_path(
&gc,
&path.transformed(&transform),
&Affine2D::identity(),
Some(self.color),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::axis::ticker::{FixedFormatter, FixedLocator, NullLocator};
#[derive(Default)]
struct CountingRenderer {
paths: usize,
verts: Vec<[f64; 2]>,
path_bboxes: Vec<(f64, f64, f64, f64)>,
}
impl Renderer for CountingRenderer {
fn draw_path(
&mut self,
_gc: &GraphicsContext,
path: &Path,
transform: &Affine2D,
_fill: Option<Rgba>,
) {
self.paths += 1;
let mut xmin = f64::INFINITY;
let mut xmax = f64::NEG_INFINITY;
let mut ymin = f64::INFINITY;
let mut ymax = f64::NEG_INFINITY;
for &[x, y] in path.vertices() {
let (tx, ty) = transform.transform_point((x, y));
self.verts.push([tx, ty]);
xmin = xmin.min(tx);
xmax = xmax.max(tx);
ymin = ymin.min(ty);
ymax = ymax.max(ty);
}
if xmin.is_finite() {
self.path_bboxes.push((xmin, xmax, ymin, ymax));
}
}
fn canvas_size(&self) -> (f64, f64) {
(300.0, 300.0)
}
}
#[test]
fn bottom_tick_positions_are_monotonic_and_span_bbox() {
let axis = Axis::new(AxisSide::Bottom);
let bbox = Bbox::from_extents(50.0, 50.0, 250.0, 250.0);
let xs: Vec<f64> = [0.0, 2.0, 4.0, 6.0, 8.0, 10.0]
.iter()
.map(|&v| axis.data_to_pixel(v, &bbox, 0.0, 10.0))
.collect();
for w in xs.windows(2) {
assert!(w[1] > w[0], "not monotonic: {xs:?}");
}
assert!((xs[0] - 50.0).abs() < 1e-9, "min was {}", xs[0]);
assert!((xs[xs.len() - 1] - 250.0).abs() < 1e-9, "max was {}", xs[5]);
}
#[test]
fn draw_emits_spine_ticks_and_labels() {
let axis = Axis::new(AxisSide::Bottom);
let bbox = Bbox::from_extents(50.0, 50.0, 250.0, 250.0);
let font = FontSource::dejavu_sans();
let mut r = CountingRenderer::default();
axis.draw(&mut r, &bbox, (0.0, 10.0), &font);
let ticks: Vec<f64> = AutoLocator::new()
.tick_values(0.0, 10.0)
.into_iter()
.filter(|&t| (0.0..=10.0).contains(&t))
.collect();
let n = ticks.len();
assert!(n >= 5, "unexpected tick count {n}");
assert!(
r.paths >= 1 + n + n,
"expected >= {} draw_path calls, got {}",
1 + 2 * n,
r.paths
);
}
#[test]
fn left_axis_ticks_point_left_of_spine() {
let axis = Axis::new(AxisSide::Left);
let bbox = Bbox::from_extents(50.0, 50.0, 250.0, 250.0);
let font = FontSource::dejavu_sans();
let mut r = CountingRenderer::default();
axis.draw(&mut r, &bbox, (0.0, 10.0), &font);
assert!(
r.verts.iter().any(|&[x, _]| x < 50.0),
"expected ink left of the left spine"
);
}
#[test]
fn tick_labels_render_mathtext_formatter_output() {
let axis = Axis::new(AxisSide::Bottom)
.with_locator(Box::new(FixedLocator::new(vec![1.0])))
.with_formatter(Box::new(FixedFormatter::new(vec!["$10^{6}$".to_string()])));
let bbox = Bbox::from_extents(50.0, 50.0, 250.0, 250.0);
let font = FontSource::dejavu_sans();
let mut r = CountingRenderer::default();
axis.draw(&mut r, &bbox, (1.0, 1.0), &font);
assert!(
r.paths > 3,
"expected mathtext label to emit multiple paths, got {}",
r.paths
);
}
#[test]
fn axis_label_renders_mathtext() {
let axis = Axis::new(AxisSide::Left)
.with_locator(Box::new(NullLocator))
.with_label("$x^2$");
let bbox = Bbox::from_extents(50.0, 50.0, 250.0, 250.0);
let font = FontSource::dejavu_sans();
let mut r = CountingRenderer::default();
axis.draw(&mut r, &bbox, (0.0, 1.0), &font);
assert!(
r.paths > 2,
"expected mathtext axis label to add multiple paths, got {}",
r.paths
);
}
#[test]
fn left_axis_label_is_rotated_vertically() {
let axis = Axis::new(AxisSide::Left)
.with_locator(Box::new(NullLocator))
.with_label("Voltage");
let bbox = Bbox::from_extents(50.0, 50.0, 250.0, 250.0);
let font = FontSource::dejavu_sans();
let mut r = CountingRenderer::default();
axis.draw(&mut r, &bbox, (0.0, 1.0), &font);
let &(xmin, xmax, ymin, ymax) = r.path_bboxes.last().expect("axis label path");
let width = xmax - xmin;
let height = ymax - ymin;
assert!(
height > width * 2.0,
"expected rotated vertical label, bbox was width={width}, height={height}"
);
let center_y = (ymin + ymax) / 2.0;
assert!(
(center_y - 150.0).abs() < 20.0,
"expected ylabel centered on axes, bbox center_y={center_y}"
);
}
#[test]
fn axis_label_offset_includes_wide_tick_label_band() {
let axis = Axis::new(AxisSide::Left)
.with_locator(Box::new(FixedLocator::new(vec![1.0])))
.with_formatter(Box::new(FixedFormatter::new(vec!["1000000".to_string()])))
.with_label("value");
let bbox = Bbox::from_extents(50.0, 50.0, 250.0, 250.0);
let font = FontSource::dejavu_sans();
let mut r = CountingRenderer::default();
axis.draw(&mut r, &bbox, (1.0, 1.0), &font);
let tick_label = r
.path_bboxes
.iter()
.rev()
.nth(1)
.expect("tick label path before axis label");
let axis_label = r.path_bboxes.last().expect("axis label path");
assert!(
axis_label.1 < tick_label.0,
"axis label should sit left of the measured tick-label band: axis={axis_label:?}, tick={tick_label:?}"
);
}
}