use super::{AxisScale, format_tick_labels_for_scale, generate_ticks_for_scale};
#[derive(Debug, Clone)]
pub struct TickLayout {
pub data_positions: Vec<f64>,
pub pixel_positions: Vec<f32>,
pub labels: Vec<String>,
pub data_range: (f64, f64),
pub pixel_range: (f32, f32),
}
fn place_normalized(normalized: f64, at_zero: f32, at_one: f32) -> f32 {
if normalized <= 0.0 {
at_zero
} else if normalized >= 1.0 {
at_one
} else {
at_zero + (normalized as f32) * (at_one - at_zero)
}
}
impl TickLayout {
pub fn compute(
data_min: f64,
data_max: f64,
pixel_min: f32,
pixel_max: f32,
scale: &AxisScale,
target_ticks: usize,
) -> Self {
let data_positions = generate_ticks_for_scale(data_min, data_max, target_ticks, scale);
let pixel_positions: Vec<f32> = data_positions
.iter()
.map(|&data_pos| {
if scale_range_is_degenerate(data_min, data_max, scale) {
pixel_min
} else {
let normalized = scale.normalized_position(data_pos, data_min, data_max);
place_normalized(normalized, pixel_min, pixel_max)
}
})
.collect();
let labels = format_tick_labels_for_scale(&data_positions, scale);
Self {
data_positions,
pixel_positions,
labels,
data_range: (data_min, data_max),
pixel_range: (pixel_min, pixel_max),
}
}
pub fn compute_y_axis(
data_min: f64,
data_max: f64,
pixel_top: f32,
pixel_bottom: f32,
scale: &AxisScale,
target_ticks: usize,
) -> Self {
let data_positions = generate_ticks_for_scale(data_min, data_max, target_ticks, scale);
let pixel_positions: Vec<f32> = data_positions
.iter()
.map(|&data_pos| {
if scale_range_is_degenerate(data_min, data_max, scale) {
pixel_bottom
} else {
let normalized = scale.normalized_position(data_pos, data_min, data_max);
place_normalized(normalized, pixel_bottom, pixel_top)
}
})
.collect();
let labels = format_tick_labels_for_scale(&data_positions, scale);
Self {
data_positions,
pixel_positions,
labels,
data_range: (data_min, data_max),
pixel_range: (pixel_top, pixel_bottom),
}
}
pub fn len(&self) -> usize {
self.data_positions.len()
}
pub fn is_empty(&self) -> bool {
self.data_positions.is_empty()
}
pub fn data_to_pixel(&self, data_value: f64) -> f32 {
let (data_min, data_max) = self.data_range;
let (pixel_min, pixel_max) = self.pixel_range;
let data_range = data_max - data_min;
let pixel_range = pixel_max - pixel_min;
if data_range.abs() < f64::EPSILON {
pixel_min
} else {
let normalized = (data_value - data_min) / data_range;
pixel_min + (normalized as f32) * pixel_range
}
}
pub fn iter(&self) -> impl Iterator<Item = (f32, &str)> {
self.pixel_positions
.iter()
.zip(self.labels.iter())
.map(|(&pos, label)| (pos, label.as_str()))
}
}
fn scale_range_is_degenerate(data_min: f64, data_max: f64, scale: &AxisScale) -> bool {
match scale {
AxisScale::Log => {
data_min <= 0.0
|| data_max <= 0.0
|| !data_min.is_finite()
|| !data_max.is_finite()
|| data_min == data_max
}
_ => (data_max - data_min).abs() < f64::EPSILON,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tick_layout_basic() {
let layout = TickLayout::compute(0.0, 100.0, 0.0, 500.0, &AxisScale::Linear, 5);
assert!(!layout.is_empty());
assert_eq!(layout.data_positions.len(), layout.pixel_positions.len());
assert_eq!(layout.data_positions.len(), layout.labels.len());
}
#[test]
fn test_log_tick_layout_uses_log_pixel_positions() {
let layout = TickLayout::compute(1.0, 1000.0, 0.0, 300.0, &AxisScale::Log, 4);
assert_eq!(layout.data_positions, vec![1.0, 10.0, 100.0, 1000.0]);
assert!((layout.pixel_positions[0] - 0.0).abs() < 0.1);
assert!((layout.pixel_positions[1] - 100.0).abs() < 0.1);
assert!((layout.pixel_positions[2] - 200.0).abs() < 0.1);
assert!((layout.pixel_positions[3] - 300.0).abs() < 0.1);
}
#[test]
fn test_log_y_tick_layout_uses_inverted_log_pixel_positions() {
let layout = TickLayout::compute_y_axis(1.0, 1000.0, 0.0, 300.0, &AxisScale::Log, 4);
assert_eq!(layout.data_positions, vec![1.0, 10.0, 100.0, 1000.0]);
assert!((layout.pixel_positions[0] - 300.0).abs() < 0.1);
assert!((layout.pixel_positions[1] - 200.0).abs() < 0.1);
assert!((layout.pixel_positions[2] - 100.0).abs() < 0.1);
assert!((layout.pixel_positions[3] - 0.0).abs() < 0.1);
}
#[test]
fn test_log_tick_layout_spreads_sub_epsilon_ticks() {
let min = f64::EPSILON / 1024.0;
let max = f64::EPSILON / 16.0;
let layout = TickLayout::compute(min, max, 0.0, 300.0, &AxisScale::Log, 8);
assert!(layout.pixel_positions.len() >= 2);
assert!(
layout
.pixel_positions
.windows(2)
.all(|pair| pair[0] < pair[1]),
"expected distinct log-space positions: {:?}",
layout.pixel_positions
);
}
#[test]
fn test_tick_layout_alignment() {
let layout = TickLayout::compute(0.0, 100.0, 0.0, 500.0, &AxisScale::Linear, 6);
for (i, &data_pos) in layout.data_positions.iter().enumerate() {
let expected_pixel = (data_pos / 100.0 * 500.0) as f32;
let actual_pixel = layout.pixel_positions[i];
assert!(
(expected_pixel - actual_pixel).abs() < 0.1,
"Pixel position mismatch at index {}: expected {}, got {}",
i,
expected_pixel,
actual_pixel
);
}
}
#[test]
fn test_tick_layout_y_axis_inverted() {
let layout = TickLayout::compute_y_axis(0.0, 100.0, 0.0, 500.0, &AxisScale::Linear, 6);
if layout.data_positions.len() >= 2 {
let first_data = layout.data_positions[0];
let last_data = layout.data_positions[layout.data_positions.len() - 1];
let first_pixel = layout.pixel_positions[0];
let last_pixel = layout.pixel_positions[layout.pixel_positions.len() - 1];
if first_data < last_data {
assert!(
first_pixel > last_pixel,
"Y-axis should be inverted: lower data = higher pixel"
);
}
}
}
#[test]
fn test_layout_labels_come_from_the_canonical_formatter() {
for (min, max, scale) in [
(0.0, 100.0, AxisScale::Linear),
(0.0, 1e6, AxisScale::Linear),
(0.0, 0.001, AxisScale::Linear),
(1.0, 1000.0, AxisScale::Log),
] {
let layout = TickLayout::compute(min, max, 0.0, 500.0, &scale, 6);
assert_eq!(
layout.labels,
format_tick_labels_for_scale(&layout.data_positions, &scale),
"layout labels diverged from the canonical formatter for ({min}, {max})"
);
}
}
#[test]
fn test_layout_labels_never_mix_notations() {
for (min, max, scale) in [
(0.0, 1e6, AxisScale::Linear),
(0.0, 0.001, AxisScale::Linear),
(99000.0, 101000.0, AxisScale::Linear),
] {
let layout = TickLayout::compute(min, max, 0.0, 500.0, &scale, 6);
let scientific = layout
.labels
.iter()
.filter(|label| label.contains('e'))
.count();
assert!(
scientific == 0 || scientific == layout.labels.len(),
"({min}, {max}) mixed notations: {:?}",
layout.labels
);
}
}
#[test]
fn test_log_layout_labels_use_superscript_decades() {
let layout = TickLayout::compute(1.0, 1000.0, 0.0, 300.0, &AxisScale::Log, 4);
assert_eq!(layout.labels, vec!["10⁰", "10¹", "10²", "10³"]);
}
#[test]
fn test_tick_layout_labels_present() {
let layout = TickLayout::compute(0.0, 100.0, 0.0, 500.0, &AxisScale::Linear, 5);
for label in &layout.labels {
assert!(!label.is_empty(), "Labels should not be empty");
}
}
#[test]
fn test_data_to_pixel() {
let layout = TickLayout::compute(0.0, 100.0, 0.0, 500.0, &AxisScale::Linear, 5);
assert!((layout.data_to_pixel(0.0) - 0.0).abs() < 0.1);
assert!((layout.data_to_pixel(50.0) - 250.0).abs() < 0.1);
assert!((layout.data_to_pixel(100.0) - 500.0).abs() < 0.1);
}
}