use astrelis_render::Color;
#[derive(Debug, Clone, PartialEq)]
pub struct DashPattern {
pub segments: Vec<f32>,
pub phase: f32,
}
impl Default for DashPattern {
fn default() -> Self {
Self::SOLID
}
}
impl DashPattern {
pub const SOLID: DashPattern = DashPattern {
segments: Vec::new(),
phase: 0.0,
};
pub fn dashed(dash: f32, gap: f32) -> Self {
Self {
segments: vec![dash, gap],
phase: 0.0,
}
}
pub fn dotted(size: f32) -> Self {
Self {
segments: vec![size, size],
phase: 0.0,
}
}
pub fn dash_dot(dash: f32, dot: f32, gap: f32) -> Self {
Self {
segments: vec![dash, gap, dot, gap],
phase: 0.0,
}
}
pub fn dash_dot_dot(dash: f32, dot: f32, gap: f32) -> Self {
Self {
segments: vec![dash, gap, dot, gap, dot, gap],
phase: 0.0,
}
}
pub fn custom(segments: Vec<f32>) -> Self {
Self {
segments,
phase: 0.0,
}
}
pub fn with_phase(mut self, phase: f32) -> Self {
self.phase = phase;
self
}
pub fn is_solid(&self) -> bool {
self.segments.is_empty()
}
pub fn cycle_length(&self) -> f32 {
self.segments.iter().sum()
}
pub fn short_dash() -> Self {
Self::dashed(4.0, 2.0)
}
pub fn medium_dash() -> Self {
Self::dashed(8.0, 4.0)
}
pub fn long_dash() -> Self {
Self::dashed(12.0, 6.0)
}
pub fn fine_dot() -> Self {
Self::dotted(1.0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GridLevel {
pub enabled: bool,
pub thickness: f32,
pub color: Color,
pub dash: DashPattern,
pub z_order: i32,
}
impl Default for GridLevel {
fn default() -> Self {
Self {
enabled: true,
thickness: 1.0,
color: Color::rgba(0.25, 0.25, 0.28, 1.0),
dash: DashPattern::SOLID,
z_order: 0,
}
}
}
impl GridLevel {
pub fn new() -> Self {
Self::default()
}
pub fn disabled() -> Self {
Self {
enabled: false,
..Default::default()
}
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_thickness(mut self, thickness: f32) -> Self {
self.thickness = thickness;
self
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn with_dash(mut self, dash: DashPattern) -> Self {
self.dash = dash;
self
}
pub fn with_z_order(mut self, z_order: i32) -> Self {
self.z_order = z_order;
self
}
pub fn dotted(mut self) -> Self {
self.dash = DashPattern::dotted(2.0);
self
}
pub fn dashed(mut self) -> Self {
self.dash = DashPattern::medium_dash();
self
}
pub fn major() -> Self {
Self {
enabled: true,
thickness: 1.0,
color: Color::rgba(0.3, 0.3, 0.33, 1.0),
dash: DashPattern::SOLID,
z_order: 0,
}
}
pub fn minor() -> Self {
Self {
enabled: true,
thickness: 0.5,
color: Color::rgba(0.2, 0.2, 0.22, 0.8),
dash: DashPattern::SOLID,
z_order: -1,
}
}
pub fn tertiary() -> Self {
Self {
enabled: false, thickness: 0.25,
color: Color::rgba(0.15, 0.15, 0.17, 0.5),
dash: DashPattern::dotted(1.0),
z_order: -2,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum GridSpacing {
Auto {
target_count: usize,
},
Fixed {
interval: f64,
},
Custom {
values: Vec<f64>,
},
LogDecades {
subdivisions: usize,
},
TimeAware,
}
impl Default for GridSpacing {
fn default() -> Self {
Self::Auto { target_count: 5 }
}
}
impl GridSpacing {
pub fn auto(count: usize) -> Self {
Self::Auto {
target_count: count,
}
}
pub fn fixed(interval: f64) -> Self {
Self::Fixed { interval }
}
pub fn custom(values: Vec<f64>) -> Self {
Self::Custom { values }
}
pub fn log_decades(subdivisions: usize) -> Self {
Self::LogDecades { subdivisions }
}
pub fn calculate_positions(
&self,
min: f64,
max: f64,
minor_divisions: usize,
) -> (Vec<f64>, Vec<f64>) {
let range = max - min;
if range.abs() < f64::EPSILON {
return (vec![], vec![]);
}
let (major, minor) = match self {
Self::Auto { target_count } => {
self.calculate_auto(min, max, *target_count, minor_divisions)
}
Self::Fixed { interval } => {
let major = self.calculate_fixed(min, max, *interval);
let minor = if minor_divisions > 1 {
self.calculate_fixed(min, max, interval / minor_divisions as f64)
.into_iter()
.filter(|v| !major.iter().any(|m| (v - m).abs() < interval * 0.01))
.collect()
} else {
vec![]
};
(major, minor)
}
Self::Custom { values } => {
let major: Vec<f64> = values
.iter()
.filter(|&&v| v >= min && v <= max)
.copied()
.collect();
(major, vec![])
}
Self::LogDecades { subdivisions } => {
self.calculate_log_decades(min, max, *subdivisions)
}
Self::TimeAware => self.calculate_time_aware(min, max, minor_divisions),
};
(major, minor)
}
fn calculate_auto(
&self,
min: f64,
max: f64,
target_count: usize,
minor_divisions: usize,
) -> (Vec<f64>, Vec<f64>) {
let range = max - min;
let rough_interval = range / target_count as f64;
let magnitude = 10f64.powf(rough_interval.log10().floor());
let normalized = rough_interval / magnitude;
let nice_interval = if normalized < 1.5 {
magnitude
} else if normalized < 3.0 {
2.0 * magnitude
} else if normalized < 7.0 {
5.0 * magnitude
} else {
10.0 * magnitude
};
let major = self.calculate_fixed(min, max, nice_interval);
let minor = if minor_divisions > 1 {
let minor_interval = nice_interval / minor_divisions as f64;
self.calculate_fixed(min, max, minor_interval)
.into_iter()
.filter(|v| !major.iter().any(|m| (v - m).abs() < nice_interval * 0.01))
.collect()
} else {
vec![]
};
(major, minor)
}
fn calculate_fixed(&self, min: f64, max: f64, interval: f64) -> Vec<f64> {
if interval <= 0.0 {
return vec![];
}
let start = (min / interval).ceil() * interval;
let mut positions = Vec::new();
let mut current = start;
while current <= max {
positions.push(current);
current += interval;
}
positions
}
fn calculate_log_decades(
&self,
min: f64,
max: f64,
subdivisions: usize,
) -> (Vec<f64>, Vec<f64>) {
if min <= 0.0 || max <= 0.0 {
return (vec![], vec![]);
}
let log_min = min.log10().floor() as i32;
let log_max = max.log10().ceil() as i32;
let mut major = Vec::new();
let mut minor = Vec::new();
for exp in log_min..=log_max {
let decade = 10f64.powi(exp);
if decade >= min && decade <= max {
major.push(decade);
}
if subdivisions > 1 {
let subdivision_values: Vec<f64> = match subdivisions {
2 => vec![2.0, 5.0],
3 => vec![2.0, 4.0, 6.0, 8.0],
_ => vec![2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
};
for &mult in &subdivision_values {
let value = decade * mult;
if value >= min && value <= max {
minor.push(value);
}
}
}
}
(major, minor)
}
fn calculate_time_aware(
&self,
min: f64,
max: f64,
minor_divisions: usize,
) -> (Vec<f64>, Vec<f64>) {
let range = max - min;
let interval = if range < 60.0 {
self.nice_time_interval(range, &[1.0, 2.0, 5.0, 10.0, 15.0, 30.0])
} else if range < 3600.0 {
self.nice_time_interval(range, &[60.0, 120.0, 300.0, 600.0, 900.0, 1800.0])
} else if range < 86400.0 {
self.nice_time_interval(range, &[3600.0, 7200.0, 10800.0, 21600.0, 43200.0])
} else if range < 604800.0 {
self.nice_time_interval(range, &[86400.0, 172800.0])
} else {
self.nice_time_interval(range, &[604800.0, 2592000.0])
};
let major = self.calculate_fixed(min, max, interval);
let minor = if minor_divisions > 1 {
let minor_interval = interval / minor_divisions as f64;
self.calculate_fixed(min, max, minor_interval)
.into_iter()
.filter(|v| !major.iter().any(|m| (v - m).abs() < interval * 0.01))
.collect()
} else {
vec![]
};
(major, minor)
}
fn nice_time_interval(&self, range: f64, candidates: &[f64]) -> f64 {
let target_count = 5;
let ideal_interval = range / target_count as f64;
candidates
.iter()
.copied()
.min_by(|&a, &b| {
let a_diff = (a - ideal_interval).abs();
let b_diff = (b - ideal_interval).abs();
a_diff.partial_cmp(&b_diff).unwrap()
})
.unwrap_or(ideal_interval)
}
}
#[derive(Debug, Clone)]
pub struct GridConfig {
pub major: GridLevel,
pub minor: Option<GridLevel>,
pub tertiary: Option<GridLevel>,
pub spacing: GridSpacing,
pub minor_divisions: usize,
pub extend_beyond_plot: bool,
}
impl Default for GridConfig {
fn default() -> Self {
Self {
major: GridLevel::major(),
minor: None,
tertiary: None,
spacing: GridSpacing::default(),
minor_divisions: 4,
extend_beyond_plot: false,
}
}
}
impl GridConfig {
pub fn new() -> Self {
Self::default()
}
pub fn none() -> Self {
Self {
major: GridLevel::disabled(),
minor: None,
tertiary: None,
..Default::default()
}
}
pub fn minimal() -> Self {
Self {
major: GridLevel::major(),
minor: None,
tertiary: None,
..Default::default()
}
}
pub fn detailed() -> Self {
Self {
major: GridLevel::major(),
minor: Some(GridLevel::minor()),
tertiary: None,
minor_divisions: 5,
..Default::default()
}
}
pub fn fine() -> Self {
Self {
major: GridLevel::major(),
minor: Some(GridLevel::minor()),
tertiary: Some(GridLevel::tertiary().with_enabled(true)),
minor_divisions: 5,
..Default::default()
}
}
pub fn with_major(mut self, major: GridLevel) -> Self {
self.major = major;
self
}
pub fn with_minor(mut self, minor: GridLevel) -> Self {
self.minor = Some(minor);
self
}
pub fn with_tertiary(mut self, tertiary: GridLevel) -> Self {
self.tertiary = Some(tertiary);
self
}
pub fn with_spacing(mut self, spacing: GridSpacing) -> Self {
self.spacing = spacing;
self
}
pub fn with_minor_divisions(mut self, divisions: usize) -> Self {
self.minor_divisions = divisions;
self
}
pub fn extend_beyond(mut self) -> Self {
self.extend_beyond_plot = true;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dash_pattern_cycle_length() {
let dashed = DashPattern::dashed(10.0, 5.0);
assert_eq!(dashed.cycle_length(), 15.0);
let solid = DashPattern::SOLID;
assert_eq!(solid.cycle_length(), 0.0);
assert!(solid.is_solid());
}
#[test]
fn test_grid_spacing_auto() {
let spacing = GridSpacing::auto(5);
let (major, _minor) = spacing.calculate_positions(0.0, 100.0, 2);
assert!(!major.is_empty());
for &pos in &major {
assert!((0.0..=100.0).contains(&pos));
}
}
#[test]
fn test_grid_spacing_fixed() {
let spacing = GridSpacing::fixed(10.0);
let (major, _) = spacing.calculate_positions(0.0, 50.0, 1);
assert_eq!(major, vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0]);
}
#[test]
fn test_grid_spacing_log_decades() {
let spacing = GridSpacing::log_decades(2);
let (major, minor) = spacing.calculate_positions(1.0, 1000.0, 1);
assert!(major.contains(&1.0));
assert!(major.contains(&10.0));
assert!(major.contains(&100.0));
assert!(major.contains(&1000.0));
assert!(!minor.is_empty());
}
#[test]
fn test_grid_config_presets() {
let minimal = GridConfig::minimal();
assert!(minimal.major.enabled);
assert!(minimal.minor.is_none());
let detailed = GridConfig::detailed();
assert!(detailed.major.enabled);
assert!(detailed.minor.is_some());
assert!(detailed.minor.as_ref().unwrap().enabled);
}
}