use crate::geometry::{Point, Rect};
use crate::layout::{Measure, WidthHint};
use crate::plot::chrome::linear_axis::{draw_linear_axis_at, AxisChromeStyle};
use crate::plot::scale::Scale;
use crate::plot::theme::{HAlign, Theme};
use crate::scales::breaks::DEFAULT_BREAK_COUNT;
use crate::scales::value::Value;
use crate::scene::SceneBuilder;
use crate::text::TextRun;
use crate::scales::chrome::AxisSide;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AxisId(u32);
impl AxisId {
pub(crate) fn new(raw: u32) -> Self {
Self(raw)
}
pub fn raw(self) -> u32 {
self.0
}
}
#[derive(Clone, Debug)]
pub struct Axis {
scale_name: Option<String>,
placement: AxisPlacement,
title: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AxisPlacement {
Cartesian(AxisSide),
PolarRadius { theta_frac: f64 },
PolarAngular(PolarRing),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PolarRing {
Outer,
Inner,
}
impl Axis {
pub fn rail(scale_name: impl Into<String>, placement: AxisPlacement) -> Self {
Self {
scale_name: Some(scale_name.into()),
placement,
title: None,
}
}
pub fn title_only(title: impl Into<String>, placement: AxisPlacement) -> Self {
Self {
scale_name: None,
placement,
title: Some(title.into()),
}
}
pub fn title(mut self, t: impl Into<String>) -> Self {
self.title = Some(t.into());
self
}
pub fn scale_name(&self) -> Option<&str> {
self.scale_name.as_deref()
}
pub fn placement(&self) -> AxisPlacement {
self.placement
}
pub fn title_ref(&self) -> Option<&str> {
self.title.as_deref()
}
}
pub(crate) struct AxisMeasure {
side: AxisSide,
max_label_w_px: f64,
max_label_h_px: f64,
tick_length_px: f64,
gap_px: f64,
}
impl AxisMeasure {
fn new(
scale: &Scale,
side: AxisSide,
dpi: f64,
chrome_style: &AxisChromeStyle,
locale: &crate::scales::Locale,
) -> Self {
let breaks = scale.breaks(DEFAULT_BREAK_COUNT);
let mut max_w: f64 = 0.0;
let mut max_h: f64 = 0.0;
for v in &breaks {
if matches!(v, Value::Null) {
continue;
}
let label = scale.format(v, locale);
let run = TextRun::new(&label, &chrome_style.text_style, dpi);
let h = run.set_max_width(f32::INFINITY, HAlign::Start) as f64;
let w = run.natural_width();
max_w = max_w.max(w);
max_h = max_h.max(h);
}
AxisMeasure {
side,
max_label_w_px: max_w,
max_label_h_px: max_h,
tick_length_px: chrome_style.tick_length_px.abs(),
gap_px: chrome_style.gap_px,
}
}
fn chrome_thickness_px(&self) -> f64 {
let label_dim = if self.side.is_vertical() {
self.max_label_w_px
} else {
self.max_label_h_px
};
self.tick_length_px + self.gap_px + label_dim
}
}
impl Measure for AxisMeasure {
fn width_hint(&self, _dpi: f64) -> WidthHint {
if self.side.is_vertical() {
WidthHint::Min(self.chrome_thickness_px())
} else {
WidthHint::Min(0.0)
}
}
fn height_at(&self, _width: f64, _dpi: f64) -> f64 {
if self.side.is_horizontal() {
self.chrome_thickness_px()
} else {
0.0
}
}
}
pub fn measure(scale: &Scale, side: AxisSide, dpi: f64, theme: &Theme) -> Box<dyn Measure> {
let (ch, side_idx) = axis_side_to_channel_side(side);
let resolved = theme.resolved_axis(ch, side_idx);
let chrome_style = AxisChromeStyle::from_resolved(
&resolved,
&theme.palette,
dpi,
crate::plot::chrome::root_text_pt(theme),
);
Box::new(AxisMeasure::new(
scale,
side,
dpi,
&chrome_style,
&theme.locale,
))
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
scale: &Scale,
scene: &mut dyn SceneBuilder,
slot_rect: Rect,
panel_rect: Rect,
side: AxisSide,
dpi: f64,
theme: &Theme,
) {
let breaks = scale.breaks(DEFAULT_BREAK_COUNT);
if breaks.is_empty() {
return;
}
let panel_w = panel_rect.x1 - panel_rect.x0;
let panel_h = panel_rect.y1 - panel_rect.y0;
if panel_w <= 0.0 || panel_h <= 0.0 {
return;
}
let (start, end, tick_direction) = match side {
AxisSide::Bottom => (
Point::new(panel_rect.x0, slot_rect.y0),
Point::new(panel_rect.x1, slot_rect.y0),
(0.0, 1.0),
),
AxisSide::Top => (
Point::new(panel_rect.x0, slot_rect.y1),
Point::new(panel_rect.x1, slot_rect.y1),
(0.0, -1.0),
),
AxisSide::Left => (
Point::new(slot_rect.x1, panel_rect.y1),
Point::new(slot_rect.x1, panel_rect.y0),
(-1.0, 0.0),
),
AxisSide::Right => (
Point::new(slot_rect.x0, panel_rect.y1),
Point::new(slot_rect.x0, panel_rect.y0),
(1.0, 0.0),
),
};
let majors: Vec<(f64, String)> = breaks
.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 (ch, side_idx) = axis_side_to_channel_side(side);
let resolved = theme.resolved_axis(ch, side_idx);
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,
);
}
pub(crate) fn axis_side_to_channel_side(side: AxisSide) -> (u8, u8) {
match side {
AxisSide::Bottom => (0, 0),
AxisSide::Top => (0, 1),
AxisSide::Left => (1, 0),
AxisSide::Right => (1, 1),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plot::scale;
use crate::scales::value::Value;
use crate::scene::recording::{Op, RecordingScene};
fn dpi_96() -> f64 {
96.0
}
fn panel_400_300() -> Rect {
Rect::new(50.0, 20.0, 450.0, 320.0)
}
#[test]
fn axis_builders_round_trip_through_the_accessors() {
let rail = Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom)).title("Time");
assert_eq!(rail.scale_name(), Some("x"));
assert_eq!(rail.placement(), AxisPlacement::Cartesian(AxisSide::Bottom));
assert_eq!(rail.title_ref(), Some("Time"));
let title_only = Axis::title_only("Depth", AxisPlacement::PolarAngular(PolarRing::Outer));
assert_eq!(title_only.scale_name(), None);
assert_eq!(title_only.title_ref(), Some("Depth"));
}
#[test]
fn bottom_axis_measure_reports_chrome_height() {
let s = scale::continuous(0.0..=100.0);
let theme = Theme::default();
let m = measure(&s, AxisSide::Bottom, dpi_96(), &theme);
assert_eq!(m.width_hint(dpi_96()), WidthHint::Min(0.0));
let h = m.height_at(400.0, dpi_96());
assert!(h > 0.0, "axis height should be positive");
assert!(h >= 1.0);
}
#[test]
fn left_axis_measure_reports_chrome_width() {
let s = scale::continuous(0.0..=100.0);
let theme = Theme::default();
let m = measure(&s, AxisSide::Left, dpi_96(), &theme);
let w = match m.width_hint(dpi_96()) {
WidthHint::Min(w) => w,
WidthHint::NeedsHeight { seed } => seed,
};
assert!(w > 0.0, "axis width should be positive");
assert_eq!(m.height_at(w, dpi_96()), 0.0);
}
#[test]
fn axis_chrome_grows_with_longer_labels() {
let s_short = scale::continuous(0.0..=10.0);
let s_long = scale::continuous(0.0..=100_000_000.0);
let theme = Theme::default();
let m_short = measure(&s_short, AxisSide::Left, dpi_96(), &theme);
let m_long = measure(&s_long, AxisSide::Left, dpi_96(), &theme);
let w_short = match m_short.width_hint(dpi_96()) {
WidthHint::Min(w) => w,
WidthHint::NeedsHeight { seed } => seed,
};
let w_long = match m_long.width_hint(dpi_96()) {
WidthHint::Min(w) => w,
WidthHint::NeedsHeight { seed } => seed,
};
assert!(
w_long > w_short,
"longer labels should grow the axis: short={w_short}, long={w_long}"
);
}
fn count_strokes_and_glyph_runs(scene: &RecordingScene) -> (usize, usize) {
let mut strokes = 0usize;
let mut glyphs = 0usize;
for op in &scene.ops {
match op {
Op::Stroke { .. } => strokes += 1,
Op::DrawGlyphs(_) => glyphs += 1,
_ => {}
}
}
(strokes, glyphs)
}
#[test]
fn bottom_axis_draws_ticks_and_labels() {
let s = scale::continuous(0.0..=10.0);
let panel = panel_400_300();
let theme = Theme::default();
let m = measure(&s, AxisSide::Bottom, dpi_96(), &theme);
let chrome_h = m.height_at(panel.x1 - panel.x0, dpi_96());
let slot = Rect::new(panel.x0, panel.y1, panel.x1, panel.y1 + chrome_h);
let mut scene = RecordingScene::default();
draw(
&s,
&mut scene,
slot,
panel,
AxisSide::Bottom,
dpi_96(),
&Theme::default(),
);
let (strokes, glyphs) = count_strokes_and_glyph_runs(&scene);
let breaks = s.breaks(DEFAULT_BREAK_COUNT);
let minors = s.minor_breaks(DEFAULT_BREAK_COUNT);
let n_majors = breaks.iter().filter(|v| !matches!(v, Value::Null)).count();
let n_minors = minors.iter().filter(|v| !matches!(v, Value::Null)).count();
let expected_strokes = n_majors + n_minors;
assert_eq!(
strokes, expected_strokes,
"expected {expected_strokes} strokes ({n_majors} majors + {n_minors} minors, no baseline); got {strokes}"
);
assert!(
glyphs >= n_majors,
"expected at least one glyph-run per major label"
);
}
#[test]
fn left_axis_draws_ticks_and_labels() {
let s = scale::continuous(0.0..=1.0);
let panel = panel_400_300();
let theme = Theme::default();
let m = measure(&s, AxisSide::Left, dpi_96(), &theme);
let chrome_w = match m.width_hint(dpi_96()) {
WidthHint::Min(w) => w,
WidthHint::NeedsHeight { seed } => seed,
};
let slot = Rect::new(panel.x0 - chrome_w, panel.y0, panel.x0, panel.y1);
let mut scene = RecordingScene::default();
draw(
&s,
&mut scene,
slot,
panel,
AxisSide::Left,
dpi_96(),
&Theme::default(),
);
let (strokes, glyphs) = count_strokes_and_glyph_runs(&scene);
let breaks = s.breaks(DEFAULT_BREAK_COUNT);
let minors = s.minor_breaks(DEFAULT_BREAK_COUNT);
let n_majors = breaks.iter().filter(|v| !matches!(v, Value::Null)).count();
let n_minors = minors.iter().filter(|v| !matches!(v, Value::Null)).count();
let expected_strokes = n_majors + n_minors;
assert_eq!(strokes, expected_strokes);
assert!(glyphs >= n_majors);
}
#[test]
fn binned_axis_ticks_sit_on_their_own_bin_edges() {
use crate::geometry::Shape as _;
let edges = vec![2500.0, 3500.0, 4500.0, 5500.0, 6500.0];
let s = scale::binned(2500.0..=6500.0, edges.clone());
let panel = panel_400_300();
let theme = Theme::default();
let m = measure(&s, AxisSide::Bottom, dpi_96(), &theme);
let chrome_h = m.height_at(panel.x1 - panel.x0, dpi_96());
let slot = Rect::new(panel.x0, panel.y1, panel.x1, panel.y1 + chrome_h);
let mut scene = RecordingScene::default();
draw(
&s,
&mut scene,
slot,
panel,
AxisSide::Bottom,
dpi_96(),
&theme,
);
let tick_xs: Vec<f64> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke { path, .. } => Some(path.bounding_box().center().x),
_ => None,
})
.collect();
let panel_w = panel.x1 - panel.x0;
let expected: Vec<f64> = edges
.iter()
.map(|e| panel.x0 + (e - 2500.0) / 4000.0 * panel_w)
.collect();
assert_eq!(tick_xs.len(), expected.len(), "one tick per bin edge");
for (got, want) in tick_xs.iter().zip(&expected) {
assert!(
(got - want).abs() < 1e-6,
"tick at {got}, expected {want} (ticks: {tick_xs:?})"
);
}
}
#[test]
fn draw_axis_with_no_breaks_is_silent() {
let s = scale::identity();
let panel = panel_400_300();
let slot = Rect::new(panel.x0, panel.y1, panel.x1, panel.y1 + 30.0);
let mut scene = RecordingScene::default();
draw(
&s,
&mut scene,
slot,
panel,
AxisSide::Bottom,
dpi_96(),
&Theme::default(),
);
assert!(
scene.ops.is_empty(),
"expected no ops for empty breaks; got {}",
scene.ops.len()
);
}
#[test]
fn draw_axis_with_degenerate_panel_is_silent() {
let s = scale::continuous(0.0..=10.0);
let panel = Rect::new(0.0, 0.0, 0.0, 0.0);
let slot = Rect::new(0.0, 0.0, 10.0, 10.0);
let mut scene = RecordingScene::default();
draw(
&s,
&mut scene,
slot,
panel,
AxisSide::Bottom,
dpi_96(),
&Theme::default(),
);
assert!(scene.ops.is_empty());
}
}