use crate::render::color::Color;
use crate::render::datetime::DateTimeAxis;
use crate::render::layout::{
AxisLabelOverlap, AxisLine, ComputedLayout, Layout, TickAlign, TickFormat, TickPos,
};
use crate::render::render::{Primitive, Scene, TextAnchor};
use crate::render::render_utils;
use crate::render::text_metrics::{
ascent, center_offset, line_height, measure_text_width, FontStyle,
};
fn draw_x_tick(
scene: &mut Scene,
layout: &Layout,
computed: &ComputedLayout,
theme: &crate::render::theme::Theme,
x: f64,
is_minor: bool,
) {
let tick_len = if is_minor {
computed.tick_mark_minor
} else {
computed.tick_mark_major
};
let y_base = computed.height - computed.margin_bottom;
let (y1, y2) = match layout.tick_align {
TickAlign::Inside => (y_base - tick_len, y_base),
TickAlign::Outside => (y_base, y_base + tick_len),
TickAlign::Center => (y_base - tick_len * 0.5, y_base + tick_len * 0.5),
};
scene.add(Primitive::Line {
x1: x,
y1,
x2: x,
y2,
stroke: Color::from(&theme.tick_color),
stroke_width: computed.tick_stroke_width,
stroke_dasharray: None,
});
if layout.tick_pos == TickPos::Both {
let y_top = computed.margin_top;
let (ty1, ty2) = match layout.tick_align {
TickAlign::Inside => (y_top, y_top + tick_len),
TickAlign::Outside => (y_top - tick_len, y_top),
TickAlign::Center => (y_top - tick_len * 0.5, y_top + tick_len * 0.5),
};
scene.add(Primitive::Line {
x1: x,
y1: ty1,
x2: x,
y2: ty2,
stroke: Color::from(&theme.tick_color),
stroke_width: computed.tick_stroke_width,
stroke_dasharray: None,
});
}
}
fn draw_y_tick(
scene: &mut Scene,
layout: &Layout,
computed: &ComputedLayout,
theme: &crate::render::theme::Theme,
y: f64,
is_minor: bool,
) {
let tick_len = if is_minor {
computed.tick_mark_minor
} else {
computed.tick_mark_major
};
let x_base = computed.margin_left;
let (x1, x2) = match layout.tick_align {
TickAlign::Inside => (x_base, x_base + tick_len),
TickAlign::Outside => (x_base - tick_len, x_base),
TickAlign::Center => (x_base - tick_len * 0.5, x_base + tick_len * 0.5),
};
scene.add(Primitive::Line {
x1,
y1: y,
x2,
y2: y,
stroke: Color::from(&theme.tick_color),
stroke_width: computed.tick_stroke_width,
stroke_dasharray: None,
});
if layout.tick_pos == TickPos::Both && layout.y2_range.is_none() {
let x_right = computed.width - computed.margin_right;
let (tx1, tx2) = match layout.tick_align {
TickAlign::Inside => (x_right - tick_len, x_right),
TickAlign::Outside => (x_right, x_right + tick_len),
TickAlign::Center => (x_right - tick_len * 0.5, x_right + tick_len * 0.5),
};
scene.add(Primitive::Line {
x1: tx1,
y1: y,
x2: tx2,
y2: y,
stroke: Color::from(&theme.tick_color),
stroke_width: computed.tick_stroke_width,
stroke_dasharray: None,
});
}
}
pub(crate) struct XLabelPlacer {
strategy: AxisLabelOverlap,
tick_size: f64,
rotate: Option<f64>,
last_right: f64,
row_right: [f64; 2],
}
impl XLabelPlacer {
pub(crate) fn new(strategy: AxisLabelOverlap, tick_size: f64, rotate: Option<f64>) -> Self {
Self {
strategy,
tick_size,
rotate,
last_right: f64::NEG_INFINITY,
row_right: [f64::NEG_INFINITY; 2],
}
}
fn footprint(&self, x: f64, label: &str, anchor: &TextAnchor) -> (f64, f64) {
let text_w = measure_text_width(label, self.tick_size, FontStyle::Regular);
let h_extent = match self.rotate {
Some(angle) => text_w * angle.to_radians().cos().abs(),
None => text_w,
};
match anchor {
TextAnchor::End => (x - h_extent, x),
TextAnchor::Start => (x, x + h_extent),
TextAnchor::Middle => (x - h_extent / 2.0, x + h_extent / 2.0),
}
}
pub(crate) fn place(&mut self, x: f64, label: &str, anchor: &TextAnchor) -> Option<f64> {
const GAP: f64 = 2.0;
match &self.strategy {
AxisLabelOverlap::Allow => Some(0.0),
AxisLabelOverlap::Thin => {
let (left, right) = self.footprint(x, label, anchor);
if left < self.last_right + GAP {
None
} else {
self.last_right = right;
Some(0.0)
}
}
AxisLabelOverlap::Stagger => {
let (left, right) = self.footprint(x, label, anchor);
let row_h = line_height(self.tick_size, FontStyle::Regular);
for row in 0..2usize {
if left >= self.row_right[row] + GAP {
self.row_right[row] = right;
return Some(row as f64 * row_h);
}
}
self.row_right[0] = right;
Some(0.0)
}
}
}
}
enum TickSpacing<'a> {
Arithmetic(f64),
Log { min: f64, max: f64 },
Calendar(&'a DateTimeAxis),
}
fn extend_with_phantom_ticks(ticks: &[f64], spacing: &TickSpacing) -> Vec<f64> {
let (Some(&first), Some(&last)) = (ticks.first(), ticks.last()) else {
return ticks.to_vec();
};
let (lo, hi) = match spacing {
TickSpacing::Arithmetic(step) => (first - step, last + step),
TickSpacing::Log { min, max } => {
let multipliers = render_utils::log_multipliers(*min, *max);
(
render_utils::log_tick_before(first, multipliers),
render_utils::log_tick_after(last, multipliers),
)
}
TickSpacing::Calendar(dt) => (dt.tick_before(first), dt.tick_after(last)),
};
let mut out = Vec::with_capacity(ticks.len() + 2);
out.push(lo);
out.extend_from_slice(ticks);
out.push(hi);
out
}
fn compute_minor_ticks(
ticks: &[f64],
spacing: &TickSpacing,
range: (f64, f64),
subdivisions: u32,
) -> Vec<f64> {
render_utils::generate_minor_ticks(&extend_with_phantom_ticks(ticks, spacing), subdivisions)
.into_iter()
.filter(|t| *t >= range.0 && *t <= range.1)
.collect()
}
fn arithmetic_step(ticks: &[f64], fallback: f64) -> f64 {
if ticks.len() >= 2 {
ticks[1] - ticks[0]
} else {
fallback
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_axis_ticks(
range: (f64, f64),
tick_step: Option<f64>,
bin_width: Option<f64>,
datetime: Option<&DateTimeAxis>,
log: bool,
target_ticks: usize,
) -> (Vec<f64>, TickSpacing<'_>) {
if let Some(step) = tick_step {
(
render_utils::generate_ticks_with_step(range.0, range.1, step),
TickSpacing::Arithmetic(step),
)
} else if let Some(bw) = bin_width {
let ticks = render_utils::generate_ticks_bin_aligned(range.0, range.1, bw, target_ticks);
let step = arithmetic_step(&ticks, bw);
(ticks, TickSpacing::Arithmetic(step))
} else if let Some(dt) = datetime {
(
dt.generate_ticks(range.0, range.1),
TickSpacing::Calendar(dt),
)
} else if log {
(
render_utils::generate_ticks_log(range.0, range.1),
TickSpacing::Log {
min: range.0,
max: range.1,
},
)
} else {
let ticks = render_utils::generate_ticks(range.0, range.1, target_ticks);
let fallback = render_utils::compute_tick_step(range.0, range.1, target_ticks);
let step = arithmetic_step(&ticks, fallback);
(ticks, TickSpacing::Arithmetic(step))
}
}
pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: &Layout) {
let map_x = |x| computed.map_x(x);
let map_y = |y| computed.map_y(y);
let theme = &computed.theme;
let (x_ticks, x_spacing) = resolve_axis_ticks(
computed.x_range,
computed.x_tick_step,
computed.x_bin_width,
layout.x_datetime.as_ref(),
layout.log_x,
computed.x_ticks,
);
let (y_ticks, y_spacing) = resolve_axis_ticks(
computed.y_range,
computed.y_tick_step,
None,
layout.y_datetime.as_ref(),
layout.log_y,
computed.y_ticks,
);
let x_minor = computed
.minor_ticks
.map(|n| compute_minor_ticks(&x_ticks, &x_spacing, computed.x_range, n));
let y_minor = computed
.minor_ticks
.map(|n| compute_minor_ticks(&y_ticks, &y_spacing, computed.y_range, n));
if computed.show_minor_grid && layout.x_categories.is_none() {
if let Some(ref mx) = x_minor {
for tx in mx {
let x = map_x(*tx);
scene.add(Primitive::Line {
x1: x,
y1: computed.margin_top,
x2: x,
y2: computed.height - computed.margin_bottom,
stroke: Color::from(&theme.grid_color),
stroke_width: computed.grid_stroke_width * 0.5,
stroke_dasharray: None,
});
}
}
if let Some(ref my) = y_minor {
for ty in my {
let y = map_y(*ty);
scene.add(Primitive::Line {
x1: computed.margin_left,
y1: y,
x2: computed.width - computed.margin_right,
y2: y,
stroke: Color::from(&theme.grid_color),
stroke_width: computed.grid_stroke_width * 0.5,
stroke_dasharray: None,
});
}
}
}
if layout.show_grid {
if layout.x_categories.is_none() && layout.y_categories.is_none() {
let x_axis_edge = computed.margin_left;
for tx in x_ticks.iter() {
if !layout.log_x
&& layout.x_datetime.is_none()
&& (map_x(*tx) - x_axis_edge).abs() < 1.0
{
continue;
}
let x = map_x(*tx);
scene.add(Primitive::Line {
x1: x,
y1: computed.margin_top,
x2: x,
y2: computed.height - computed.margin_bottom,
stroke: Color::from(&theme.grid_color),
stroke_width: computed.grid_stroke_width,
stroke_dasharray: None,
});
}
}
if layout.y_categories.is_none() {
let y_axis_edge = computed.height - computed.margin_bottom;
for ty in y_ticks.iter() {
if !layout.log_y
&& layout.y_datetime.is_none()
&& (map_y(*ty) - y_axis_edge).abs() < 1.0
{
continue;
}
let y = map_y(*ty);
scene.add(Primitive::Line {
x1: computed.margin_left,
y1: y,
x2: computed.width - computed.margin_right,
y2: y,
stroke: Color::from(&theme.grid_color),
stroke_width: computed.grid_stroke_width,
stroke_dasharray: None,
});
}
}
}
scene.add(Primitive::Line {
x1: computed.margin_left,
y1: computed.height - computed.margin_bottom,
x2: computed.width - computed.margin_right,
y2: computed.height - computed.margin_bottom,
stroke: Color::from(&theme.axis_color),
stroke_width: computed.axis_line_width,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: computed.margin_left,
y1: computed.margin_top,
x2: computed.margin_left,
y2: computed.height - computed.margin_bottom,
stroke: Color::from(&theme.axis_color),
stroke_width: computed.axis_line_width,
stroke_dasharray: None,
});
if let Some(categories) = &layout.y_categories {
if !layout.suppress_y_ticks {
for (i, label) in categories.iter().enumerate() {
let y_val = i as f64 + 1.0;
let y_pos = computed.map_y(y_val);
scene.add(Primitive::Text {
x: computed.margin_left - computed.tick_label_margin,
y: y_pos + center_offset(computed.tick_size as f64, FontStyle::Regular),
content: label.clone(),
size: computed.tick_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
draw_y_tick(scene, layout, computed, theme, y_pos, false);
}
}
if !layout.suppress_x_ticks {
if let Some(x_cats) = &layout.x_categories {
let mut placer = XLabelPlacer::new(
computed.x_label_overlap.clone(),
computed.tick_size as f64,
layout.x_tick_rotate,
);
for (i, label) in x_cats.iter().enumerate() {
let x_val = i as f64 + 1.0;
let x_pos = computed.map_x(x_val);
let (anchor, rotate) = match layout.x_tick_rotate {
Some(angle) if angle < 0.0 => (TextAnchor::End, Some(angle)),
Some(angle) => (TextAnchor::Start, Some(angle)),
None => (TextAnchor::Middle, None),
};
let base_y = computed.height - computed.margin_bottom
+ computed.tick_mark_major
+ ascent(computed.tick_size as f64, FontStyle::Regular);
if let Some(y_off) = placer.place(x_pos, label, &anchor) {
scene.add(Primitive::Text {
x: x_pos,
y: base_y + y_off,
content: label.clone(),
size: computed.tick_size,
anchor,
rotate,
bold: false,
color: None,
});
}
draw_x_tick(scene, layout, computed, theme, x_pos, false);
}
} else {
let mut placer = XLabelPlacer::new(
computed.x_label_overlap.clone(),
computed.tick_size as f64,
layout.x_tick_rotate,
);
for tx in x_ticks.iter() {
let x = map_x(*tx);
draw_x_tick(scene, layout, computed, theme, x, false);
let label = if let Some(ref dt) = layout.x_datetime {
dt.format_tick(*tx)
} else if layout.log_x && matches!(computed.x_tick_format, TickFormat::Auto) {
render_utils::format_log_tick(*tx)
} else {
computed.x_tick_format.format(*tx)
};
let (anchor, rotate) = match layout.x_tick_rotate {
Some(angle) => (TextAnchor::End, Some(angle)),
None => (TextAnchor::Middle, None),
};
let base_y = computed.height - computed.margin_bottom
+ computed.tick_mark_major
+ ascent(computed.tick_size as f64, FontStyle::Regular);
if let Some(y_off) = placer.place(x, &label, &anchor) {
scene.add(Primitive::Text {
x,
y: base_y + y_off,
content: label,
size: computed.tick_size,
anchor,
rotate,
bold: false,
color: None,
});
}
}
}
}
} else if let Some(categories) = &layout.x_categories {
if !layout.suppress_x_ticks {
let mut placer = XLabelPlacer::new(
computed.x_label_overlap.clone(),
computed.tick_size as f64,
layout.x_tick_rotate,
);
for (i, label) in categories.iter().enumerate() {
let x_val = i as f64 + 1.0;
let x_pos = computed.map_x(x_val);
let (anchor, rotate) = match layout.x_tick_rotate {
Some(angle) if angle < 0.0 => (TextAnchor::End, Some(angle)),
Some(angle) => (TextAnchor::Start, Some(angle)),
None => (TextAnchor::Middle, None),
};
let base_y = computed.height - computed.margin_bottom
+ computed.tick_mark_major
+ ascent(computed.tick_size as f64, FontStyle::Regular);
if let Some(y_off) = placer.place(x_pos, label, &anchor) {
scene.add(Primitive::Text {
x: x_pos,
y: base_y + y_off,
content: label.clone(),
size: computed.tick_size,
anchor,
rotate,
bold: false,
color: None,
});
}
draw_x_tick(scene, layout, computed, theme, x_pos, false);
}
}
if !layout.suppress_y_ticks {
for ty in y_ticks.iter() {
let y = map_y(*ty);
draw_y_tick(scene, layout, computed, theme, y, false);
let label = if let Some(ref dt) = layout.y_datetime {
dt.format_tick(*ty)
} else if layout.log_y && matches!(computed.y_tick_format, TickFormat::Auto) {
render_utils::format_log_tick(*ty)
} else {
computed.y_tick_format.format(*ty)
};
scene.add(Primitive::Text {
x: computed.margin_left - computed.tick_label_margin,
y: y + center_offset(computed.tick_size as f64, FontStyle::Regular),
content: label,
size: computed.tick_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
}
}
else {
if !layout.suppress_x_ticks {
let mut placer = XLabelPlacer::new(
computed.x_label_overlap.clone(),
computed.tick_size as f64,
layout.x_tick_rotate,
);
for tx in x_ticks.iter() {
let x = map_x(*tx);
draw_x_tick(scene, layout, computed, theme, x, false);
let label = if let Some(ref dt) = layout.x_datetime {
dt.format_tick(*tx)
} else if layout.log_x && matches!(computed.x_tick_format, TickFormat::Auto) {
render_utils::format_log_tick(*tx)
} else {
computed.x_tick_format.format(*tx)
};
let (anchor, rotate) = match layout.x_tick_rotate {
Some(angle) if angle < 0.0 => (TextAnchor::End, Some(angle)),
Some(angle) => (TextAnchor::Start, Some(angle)),
None => (TextAnchor::Middle, None),
};
let base_y = computed.height - computed.margin_bottom
+ computed.tick_mark_major
+ ascent(computed.tick_size as f64, FontStyle::Regular);
if let Some(y_off) = placer.place(x, &label, &anchor) {
scene.add(Primitive::Text {
x,
y: base_y + y_off,
content: label,
size: computed.tick_size,
anchor,
rotate,
bold: false,
color: None,
});
}
}
}
if !layout.suppress_y_ticks {
for ty in y_ticks.iter() {
let y = map_y(*ty);
draw_y_tick(scene, layout, computed, theme, y, false);
let label = if let Some(ref dt) = layout.y_datetime {
dt.format_tick(*ty)
} else if layout.log_y && matches!(computed.y_tick_format, TickFormat::Auto) {
render_utils::format_log_tick(*ty)
} else {
computed.y_tick_format.format(*ty)
};
scene.add(Primitive::Text {
x: computed.margin_left - computed.tick_label_margin,
y: y + center_offset(computed.tick_size as f64, FontStyle::Regular),
content: label,
size: computed.tick_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
}
if !layout.suppress_x_ticks {
if let Some(ref mx) = x_minor {
for tx in mx {
let x = map_x(*tx);
draw_x_tick(scene, layout, computed, theme, x, true);
}
}
}
if !layout.suppress_y_ticks {
if let Some(ref my) = y_minor {
for ty in my {
let y = map_y(*ty);
draw_y_tick(scene, layout, computed, theme, y, true);
}
}
}
}
if layout.axis_line == AxisLine::Box || layout.tick_pos == TickPos::Both {
scene.add(Primitive::Line {
x1: computed.margin_left,
y1: computed.margin_top,
x2: computed.width - computed.margin_right,
y2: computed.margin_top,
stroke: Color::from(&theme.axis_color),
stroke_width: computed.axis_line_width,
stroke_dasharray: None,
});
if layout.y2_range.is_none() {
scene.add(Primitive::Line {
x1: computed.width - computed.margin_right,
y1: computed.margin_top,
x2: computed.width - computed.margin_right,
y2: computed.height - computed.margin_bottom,
stroke: Color::from(&theme.axis_color),
stroke_width: computed.axis_line_width,
stroke_dasharray: None,
});
}
}
}
pub fn add_y2_axis(scene: &mut Scene, computed: &ComputedLayout, layout: &Layout) {
let Some((y2_min, y2_max)) = computed.y2_range else {
return;
};
let theme = &computed.theme;
let axis_x = computed.width - computed.margin_right;
scene.add(Primitive::Line {
x1: axis_x,
y1: computed.margin_top,
x2: axis_x,
y2: computed.height - computed.margin_bottom,
stroke: Color::from(&theme.axis_color),
stroke_width: computed.axis_line_width,
stroke_dasharray: None,
});
if layout.suppress_y2_ticks {
return;
}
let y2_ticks = if layout.log_y2 {
render_utils::generate_ticks_log(y2_min, y2_max)
} else {
render_utils::generate_ticks(y2_min, y2_max, computed.y_ticks)
};
for ty in y2_ticks.iter() {
let y = computed.map_y2(*ty);
let (tx1, tx2) = match layout.tick_align {
TickAlign::Inside => (axis_x - computed.tick_mark_major, axis_x),
TickAlign::Outside => (axis_x, axis_x + computed.tick_mark_major),
TickAlign::Center => (
axis_x - computed.tick_mark_major * 0.5,
axis_x + computed.tick_mark_major * 0.5,
),
};
scene.add(Primitive::Line {
x1: tx1,
y1: y,
x2: tx2,
y2: y,
stroke: Color::from(&theme.tick_color),
stroke_width: computed.tick_stroke_width,
stroke_dasharray: None,
});
let label = if layout.log_y2 && matches!(computed.y2_tick_format, TickFormat::Auto) {
render_utils::format_log_tick(*ty)
} else {
computed.y2_tick_format.format(*ty)
};
scene.add(Primitive::Text {
x: axis_x + computed.tick_label_margin,
y: y + center_offset(computed.tick_size as f64, FontStyle::Regular),
content: label,
size: computed.tick_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
if let Some(ref label) = layout.y2_label {
let lines = render_utils::wrap_or_single(label, computed.y2_label_wrap);
let ls = computed.label_size as f64;
let lh = line_height(ls, FontStyle::Regular);
let (dx, dy) = layout.y2_label_offset;
let base_x = axis_x + computed.y2_axis_width - ls * 0.5 + dx;
let base_y = computed.margin_top + computed.plot_height() / 2.0 + dy;
for (i, line) in lines.iter().enumerate() {
scene.add(Primitive::Text {
x: base_x - i as f64 * lh,
y: base_y,
content: line.clone(),
size: computed.label_size,
anchor: TextAnchor::Middle,
rotate: Some(90.0),
bold: false,
color: None,
});
}
}
}
pub fn add_labels_and_title(scene: &mut Scene, computed: &ComputedLayout, layout: &Layout) {
let ls = computed.label_size as f64;
let lh = line_height(ls, FontStyle::Regular);
if let Some(label) = &layout.x_label {
let lines = render_utils::wrap_or_single(label, computed.x_label_wrap);
let (dx, dy) = layout.x_label_offset;
let default_x = computed.margin_left + computed.plot_width() / 2.0;
let default_y = computed.height
- computed.legend_bottom_extra
- ls * 0.5
- (lines.len() as f64 - 1.0) * lh;
let (lx, ly) = computed.dice_x_label_pos.unwrap_or((default_x, default_y));
for (i, line) in lines.iter().enumerate() {
scene.add(Primitive::Text {
x: lx + dx,
y: ly + dy + i as f64 * lh,
content: line.clone(),
size: computed.label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
if !layout.suppress_y_ticks {
if let Some(label) = &layout.y_label {
let lines = render_utils::wrap_or_single(label, computed.y_label_wrap);
let (dx, dy) = layout.y_label_offset;
let default_x = (computed.margin_left
- 8.0
- computed.y_tick_label_px
- 5.0
- ls * 0.5
- (lines.len() as f64 - 1.0) * lh)
.max(ls * 0.5 + 8.0);
let default_y = computed.margin_top + computed.plot_height() / 2.0;
let (lx, ly) = computed.dice_y_label_pos.unwrap_or((default_x, default_y));
for (i, line) in lines.iter().enumerate() {
scene.add(Primitive::Text {
x: lx + dx + i as f64 * lh,
y: ly + dy,
content: line.clone(),
size: computed.label_size,
anchor: TextAnchor::Middle,
rotate: Some(-90.0),
bold: false,
color: None,
});
}
}
}
if let Some(title) = &layout.title {
let lines = render_utils::wrap_or_single(title, computed.title_wrap);
let ts = computed.title_size as f64;
let tlh = line_height(ts, FontStyle::Regular);
let total_height = lines.len() as f64 * tlh;
let cx = computed.margin_left + computed.plot_width() / 2.0;
let start_y = computed.title_y - total_height / 2.0 + ascent(ts, FontStyle::Regular);
for (i, line) in lines.iter().enumerate() {
scene.add(Primitive::Text {
x: cx,
y: start_y + i as f64 * tlh,
content: line.clone(),
size: computed.title_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arithmetic_ticks_extrapolate_by_the_real_step_not_a_guessed_ratio() {
let ticks = vec![5.0, 10.0, 15.0, 20.0]; let extended = extend_with_phantom_ticks(&ticks, &TickSpacing::Arithmetic(5.0));
assert_eq!(extended, vec![0.0, 5.0, 10.0, 15.0, 20.0, 25.0]);
}
#[test]
fn log_axis_with_explicit_tick_step_resolves_to_arithmetic_spacing() {
let (ticks, spacing) = resolve_axis_ticks(
(1.0, 20.0),
Some(5.0), None,
None,
true, 5,
);
assert_eq!(ticks, vec![5.0, 10.0, 15.0, 20.0]);
assert!(
matches!(spacing, TickSpacing::Arithmetic(step) if step == 5.0),
"explicit tick step must win over log-scale extrapolation"
);
}
#[test]
fn log_ticks_extrapolate_past_a_5x_multiplier_to_the_next_decade() {
let ticks = vec![1.0, 2.0, 5.0, 10.0, 20.0]; let extended = extend_with_phantom_ticks(
&ticks,
&TickSpacing::Log {
min: 1.0,
max: 35.0,
},
);
assert_eq!(extended, vec![0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0]);
}
#[test]
fn calendar_ticks_extrapolate_by_calendar_month_not_a_fixed_delta() {
use crate::render::datetime::ymd;
let dt = DateTimeAxis::months("%Y-%m-%d");
let ticks = vec![ymd(2024, 12, 1), ymd(2025, 1, 1), ymd(2025, 2, 1)];
let extended = extend_with_phantom_ticks(&ticks, &TickSpacing::Calendar(&dt));
assert_eq!(extended[0], ymd(2024, 11, 1));
assert_eq!(*extended.last().unwrap(), ymd(2025, 3, 1));
}
#[test]
fn phantom_ticks_generate_minors_covering_the_leading_and_trailing_bands() {
let ticks = vec![10.0, 20.0, 30.0];
let minors = compute_minor_ticks(&ticks, &TickSpacing::Arithmetic(10.0), (3.0, 34.0), 5);
assert!(
minors.iter().any(|&m| m < 10.0),
"expected a minor tick in the leading band below 10.0, got {minors:?}"
);
assert!(
minors.iter().any(|&m| m > 30.0),
"expected a minor tick in the trailing band above 30.0, got {minors:?}"
);
assert!(
minors.iter().all(|&m| m >= 3.0 && m <= 34.0),
"no minor tick should fall outside the axis range, got {minors:?}"
);
}
}