Skip to main content

astrelis_geometry/chart/
grid.rs

1//! Grid configuration and rendering for charts.
2//!
3//! Provides configurable grid lines with:
4//! - Multiple grid levels (major, minor, tertiary)
5//! - Dash patterns
6//! - Automatic or custom spacing
7
8use astrelis_render::Color;
9
10/// Line dash pattern.
11///
12/// Defines how a line is rendered with alternating on/off segments.
13/// An empty segments array indicates a solid line.
14#[derive(Debug, Clone, PartialEq)]
15pub struct DashPattern {
16    /// Alternating lengths: [on, off, on, off, ...]
17    ///
18    /// Empty = solid line.
19    pub segments: Vec<f32>,
20
21    /// Phase offset (starting position in the pattern).
22    pub phase: f32,
23}
24
25impl Default for DashPattern {
26    fn default() -> Self {
27        Self::SOLID
28    }
29}
30
31impl DashPattern {
32    /// Solid line (no dashes).
33    pub const SOLID: DashPattern = DashPattern {
34        segments: Vec::new(),
35        phase: 0.0,
36    };
37
38    /// Create a dashed line pattern.
39    ///
40    /// # Arguments
41    ///
42    /// * `dash` - Length of the dash (on segment)
43    /// * `gap` - Length of the gap (off segment)
44    pub fn dashed(dash: f32, gap: f32) -> Self {
45        Self {
46            segments: vec![dash, gap],
47            phase: 0.0,
48        }
49    }
50
51    /// Create a dotted line pattern.
52    ///
53    /// # Arguments
54    ///
55    /// * `size` - Dot size and gap size
56    pub fn dotted(size: f32) -> Self {
57        Self {
58            segments: vec![size, size],
59            phase: 0.0,
60        }
61    }
62
63    /// Create a dash-dot pattern.
64    ///
65    /// # Arguments
66    ///
67    /// * `dash` - Length of the dash
68    /// * `dot` - Length of the dot
69    /// * `gap` - Length of gaps
70    pub fn dash_dot(dash: f32, dot: f32, gap: f32) -> Self {
71        Self {
72            segments: vec![dash, gap, dot, gap],
73            phase: 0.0,
74        }
75    }
76
77    /// Create a dash-dot-dot pattern.
78    pub fn dash_dot_dot(dash: f32, dot: f32, gap: f32) -> Self {
79        Self {
80            segments: vec![dash, gap, dot, gap, dot, gap],
81            phase: 0.0,
82        }
83    }
84
85    /// Create a pattern from explicit segments.
86    pub fn custom(segments: Vec<f32>) -> Self {
87        Self {
88            segments,
89            phase: 0.0,
90        }
91    }
92
93    /// Set the phase offset.
94    pub fn with_phase(mut self, phase: f32) -> Self {
95        self.phase = phase;
96        self
97    }
98
99    /// Check if this is a solid line (no pattern).
100    pub fn is_solid(&self) -> bool {
101        self.segments.is_empty()
102    }
103
104    /// Get the total length of one pattern cycle.
105    pub fn cycle_length(&self) -> f32 {
106        self.segments.iter().sum()
107    }
108
109    /// Standard presets
110    pub fn short_dash() -> Self {
111        Self::dashed(4.0, 2.0)
112    }
113
114    pub fn medium_dash() -> Self {
115        Self::dashed(8.0, 4.0)
116    }
117
118    pub fn long_dash() -> Self {
119        Self::dashed(12.0, 6.0)
120    }
121
122    pub fn fine_dot() -> Self {
123        Self::dotted(1.0)
124    }
125}
126
127/// Configuration for a single grid level (major, minor, or tertiary).
128#[derive(Debug, Clone, PartialEq)]
129pub struct GridLevel {
130    /// Whether this grid level is enabled.
131    pub enabled: bool,
132
133    /// Line thickness in pixels.
134    pub thickness: f32,
135
136    /// Line color.
137    pub color: Color,
138
139    /// Dash pattern (solid by default).
140    pub dash: DashPattern,
141
142    /// Z-order for rendering (higher = on top).
143    pub z_order: i32,
144}
145
146impl Default for GridLevel {
147    fn default() -> Self {
148        Self {
149            enabled: true,
150            thickness: 1.0,
151            color: Color::rgba(0.25, 0.25, 0.28, 1.0),
152            dash: DashPattern::SOLID,
153            z_order: 0,
154        }
155    }
156}
157
158impl GridLevel {
159    /// Create a new grid level.
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Create a disabled grid level.
165    pub fn disabled() -> Self {
166        Self {
167            enabled: false,
168            ..Default::default()
169        }
170    }
171
172    /// Set whether this level is enabled.
173    pub fn with_enabled(mut self, enabled: bool) -> Self {
174        self.enabled = enabled;
175        self
176    }
177
178    /// Set the line thickness.
179    pub fn with_thickness(mut self, thickness: f32) -> Self {
180        self.thickness = thickness;
181        self
182    }
183
184    /// Set the line color.
185    pub fn with_color(mut self, color: Color) -> Self {
186        self.color = color;
187        self
188    }
189
190    /// Set the dash pattern.
191    pub fn with_dash(mut self, dash: DashPattern) -> Self {
192        self.dash = dash;
193        self
194    }
195
196    /// Set the z-order.
197    pub fn with_z_order(mut self, z_order: i32) -> Self {
198        self.z_order = z_order;
199        self
200    }
201
202    /// Make this a dotted line.
203    pub fn dotted(mut self) -> Self {
204        self.dash = DashPattern::dotted(2.0);
205        self
206    }
207
208    /// Make this a dashed line.
209    pub fn dashed(mut self) -> Self {
210        self.dash = DashPattern::medium_dash();
211        self
212    }
213
214    /// Create a major grid level preset.
215    pub fn major() -> Self {
216        Self {
217            enabled: true,
218            thickness: 1.0,
219            color: Color::rgba(0.3, 0.3, 0.33, 1.0),
220            dash: DashPattern::SOLID,
221            z_order: 0,
222        }
223    }
224
225    /// Create a minor grid level preset.
226    pub fn minor() -> Self {
227        Self {
228            enabled: true,
229            thickness: 0.5,
230            color: Color::rgba(0.2, 0.2, 0.22, 0.8),
231            dash: DashPattern::SOLID,
232            z_order: -1,
233        }
234    }
235
236    /// Create a tertiary (very fine) grid level preset.
237    pub fn tertiary() -> Self {
238        Self {
239            enabled: false, // Disabled by default
240            thickness: 0.25,
241            color: Color::rgba(0.15, 0.15, 0.17, 0.5),
242            dash: DashPattern::dotted(1.0),
243            z_order: -2,
244        }
245    }
246}
247
248/// Grid spacing strategy.
249///
250/// Determines how grid lines are positioned.
251#[derive(Debug, Clone, PartialEq)]
252pub enum GridSpacing {
253    /// Automatic spacing targeting a specific number of lines.
254    Auto {
255        /// Target number of major grid lines.
256        target_count: usize,
257    },
258
259    /// Fixed interval between grid lines.
260    Fixed {
261        /// Interval in data units.
262        interval: f64,
263    },
264
265    /// Custom grid line positions.
266    Custom {
267        /// Explicit data values where grid lines should appear.
268        values: Vec<f64>,
269    },
270
271    /// Logarithmic decades (for log scales).
272    LogDecades {
273        /// Number of subdivisions per decade (1, 2, 5, or 10 are common).
274        subdivisions: usize,
275    },
276
277    /// Time-aware spacing (smart intervals for time data).
278    ///
279    /// Automatically chooses intervals like seconds, minutes, hours, etc.
280    TimeAware,
281}
282
283impl Default for GridSpacing {
284    fn default() -> Self {
285        Self::Auto { target_count: 5 }
286    }
287}
288
289impl GridSpacing {
290    /// Create auto spacing with the given target count.
291    pub fn auto(count: usize) -> Self {
292        Self::Auto {
293            target_count: count,
294        }
295    }
296
297    /// Create fixed spacing with the given interval.
298    pub fn fixed(interval: f64) -> Self {
299        Self::Fixed { interval }
300    }
301
302    /// Create custom spacing with explicit values.
303    pub fn custom(values: Vec<f64>) -> Self {
304        Self::Custom { values }
305    }
306
307    /// Create log decade spacing.
308    pub fn log_decades(subdivisions: usize) -> Self {
309        Self::LogDecades { subdivisions }
310    }
311
312    /// Calculate grid line positions for the given range.
313    ///
314    /// Returns (major_positions, minor_positions).
315    pub fn calculate_positions(
316        &self,
317        min: f64,
318        max: f64,
319        minor_divisions: usize,
320    ) -> (Vec<f64>, Vec<f64>) {
321        let range = max - min;
322        if range.abs() < f64::EPSILON {
323            return (vec![], vec![]);
324        }
325
326        let (major, minor) = match self {
327            Self::Auto { target_count } => {
328                self.calculate_auto(min, max, *target_count, minor_divisions)
329            }
330
331            Self::Fixed { interval } => {
332                let major = self.calculate_fixed(min, max, *interval);
333                let minor = if minor_divisions > 1 {
334                    self.calculate_fixed(min, max, interval / minor_divisions as f64)
335                        .into_iter()
336                        .filter(|v| !major.iter().any(|m| (v - m).abs() < interval * 0.01))
337                        .collect()
338                } else {
339                    vec![]
340                };
341                (major, minor)
342            }
343
344            Self::Custom { values } => {
345                let major: Vec<f64> = values
346                    .iter()
347                    .filter(|&&v| v >= min && v <= max)
348                    .copied()
349                    .collect();
350                (major, vec![])
351            }
352
353            Self::LogDecades { subdivisions } => {
354                self.calculate_log_decades(min, max, *subdivisions)
355            }
356
357            Self::TimeAware => self.calculate_time_aware(min, max, minor_divisions),
358        };
359
360        (major, minor)
361    }
362
363    fn calculate_auto(
364        &self,
365        min: f64,
366        max: f64,
367        target_count: usize,
368        minor_divisions: usize,
369    ) -> (Vec<f64>, Vec<f64>) {
370        let range = max - min;
371
372        // Calculate a "nice" interval
373        let rough_interval = range / target_count as f64;
374        let magnitude = 10f64.powf(rough_interval.log10().floor());
375        let normalized = rough_interval / magnitude;
376
377        let nice_interval = if normalized < 1.5 {
378            magnitude
379        } else if normalized < 3.0 {
380            2.0 * magnitude
381        } else if normalized < 7.0 {
382            5.0 * magnitude
383        } else {
384            10.0 * magnitude
385        };
386
387        let major = self.calculate_fixed(min, max, nice_interval);
388
389        let minor = if minor_divisions > 1 {
390            let minor_interval = nice_interval / minor_divisions as f64;
391            self.calculate_fixed(min, max, minor_interval)
392                .into_iter()
393                .filter(|v| !major.iter().any(|m| (v - m).abs() < nice_interval * 0.01))
394                .collect()
395        } else {
396            vec![]
397        };
398
399        (major, minor)
400    }
401
402    fn calculate_fixed(&self, min: f64, max: f64, interval: f64) -> Vec<f64> {
403        if interval <= 0.0 {
404            return vec![];
405        }
406
407        let start = (min / interval).ceil() * interval;
408        let mut positions = Vec::new();
409        let mut current = start;
410
411        while current <= max {
412            positions.push(current);
413            current += interval;
414        }
415
416        positions
417    }
418
419    fn calculate_log_decades(
420        &self,
421        min: f64,
422        max: f64,
423        subdivisions: usize,
424    ) -> (Vec<f64>, Vec<f64>) {
425        if min <= 0.0 || max <= 0.0 {
426            return (vec![], vec![]);
427        }
428
429        let log_min = min.log10().floor() as i32;
430        let log_max = max.log10().ceil() as i32;
431
432        let mut major = Vec::new();
433        let mut minor = Vec::new();
434
435        for exp in log_min..=log_max {
436            let decade = 10f64.powi(exp);
437            if decade >= min && decade <= max {
438                major.push(decade);
439            }
440
441            if subdivisions > 1 {
442                let subdivision_values: Vec<f64> = match subdivisions {
443                    2 => vec![2.0, 5.0],
444                    3 => vec![2.0, 4.0, 6.0, 8.0],
445                    _ => vec![2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
446                };
447
448                for &mult in &subdivision_values {
449                    let value = decade * mult;
450                    if value >= min && value <= max {
451                        minor.push(value);
452                    }
453                }
454            }
455        }
456
457        (major, minor)
458    }
459
460    fn calculate_time_aware(
461        &self,
462        min: f64,
463        max: f64,
464        minor_divisions: usize,
465    ) -> (Vec<f64>, Vec<f64>) {
466        let range = max - min;
467
468        // Choose appropriate interval based on range
469        let interval = if range < 60.0 {
470            // Less than a minute: use seconds
471            self.nice_time_interval(range, &[1.0, 2.0, 5.0, 10.0, 15.0, 30.0])
472        } else if range < 3600.0 {
473            // Less than an hour: use minutes
474            self.nice_time_interval(range, &[60.0, 120.0, 300.0, 600.0, 900.0, 1800.0])
475        } else if range < 86400.0 {
476            // Less than a day: use hours
477            self.nice_time_interval(range, &[3600.0, 7200.0, 10800.0, 21600.0, 43200.0])
478        } else if range < 604800.0 {
479            // Less than a week: use days
480            self.nice_time_interval(range, &[86400.0, 172800.0])
481        } else {
482            // Use weeks or months
483            self.nice_time_interval(range, &[604800.0, 2592000.0])
484        };
485
486        let major = self.calculate_fixed(min, max, interval);
487
488        let minor = if minor_divisions > 1 {
489            let minor_interval = interval / minor_divisions as f64;
490            self.calculate_fixed(min, max, minor_interval)
491                .into_iter()
492                .filter(|v| !major.iter().any(|m| (v - m).abs() < interval * 0.01))
493                .collect()
494        } else {
495            vec![]
496        };
497
498        (major, minor)
499    }
500
501    fn nice_time_interval(&self, range: f64, candidates: &[f64]) -> f64 {
502        let target_count = 5;
503        let ideal_interval = range / target_count as f64;
504
505        candidates
506            .iter()
507            .copied()
508            .min_by(|&a, &b| {
509                let a_diff = (a - ideal_interval).abs();
510                let b_diff = (b - ideal_interval).abs();
511                a_diff.partial_cmp(&b_diff).unwrap()
512            })
513            .unwrap_or(ideal_interval)
514    }
515}
516
517/// Complete grid configuration for an axis.
518#[derive(Debug, Clone)]
519pub struct GridConfig {
520    /// Major grid lines.
521    pub major: GridLevel,
522
523    /// Minor grid lines (between major lines).
524    pub minor: Option<GridLevel>,
525
526    /// Tertiary grid lines (finest level).
527    pub tertiary: Option<GridLevel>,
528
529    /// Spacing strategy for grid lines.
530    pub spacing: GridSpacing,
531
532    /// Number of minor divisions between major grid lines.
533    pub minor_divisions: usize,
534
535    /// Whether grid lines extend beyond the plot area.
536    pub extend_beyond_plot: bool,
537}
538
539impl Default for GridConfig {
540    fn default() -> Self {
541        Self {
542            major: GridLevel::major(),
543            minor: None,
544            tertiary: None,
545            spacing: GridSpacing::default(),
546            minor_divisions: 4,
547            extend_beyond_plot: false,
548        }
549    }
550}
551
552impl GridConfig {
553    /// Create a new grid configuration.
554    pub fn new() -> Self {
555        Self::default()
556    }
557
558    /// Create a configuration with no grid.
559    pub fn none() -> Self {
560        Self {
561            major: GridLevel::disabled(),
562            minor: None,
563            tertiary: None,
564            ..Default::default()
565        }
566    }
567
568    /// Create a minimal grid (major lines only).
569    pub fn minimal() -> Self {
570        Self {
571            major: GridLevel::major(),
572            minor: None,
573            tertiary: None,
574            ..Default::default()
575        }
576    }
577
578    /// Create a detailed grid (major + minor).
579    pub fn detailed() -> Self {
580        Self {
581            major: GridLevel::major(),
582            minor: Some(GridLevel::minor()),
583            tertiary: None,
584            minor_divisions: 5,
585            ..Default::default()
586        }
587    }
588
589    /// Create a very detailed grid (major + minor + tertiary).
590    pub fn fine() -> Self {
591        Self {
592            major: GridLevel::major(),
593            minor: Some(GridLevel::minor()),
594            tertiary: Some(GridLevel::tertiary().with_enabled(true)),
595            minor_divisions: 5,
596            ..Default::default()
597        }
598    }
599
600    /// Set the major grid level.
601    pub fn with_major(mut self, major: GridLevel) -> Self {
602        self.major = major;
603        self
604    }
605
606    /// Set the minor grid level.
607    pub fn with_minor(mut self, minor: GridLevel) -> Self {
608        self.minor = Some(minor);
609        self
610    }
611
612    /// Set the tertiary grid level.
613    pub fn with_tertiary(mut self, tertiary: GridLevel) -> Self {
614        self.tertiary = Some(tertiary);
615        self
616    }
617
618    /// Set the spacing strategy.
619    pub fn with_spacing(mut self, spacing: GridSpacing) -> Self {
620        self.spacing = spacing;
621        self
622    }
623
624    /// Set the number of minor divisions.
625    pub fn with_minor_divisions(mut self, divisions: usize) -> Self {
626        self.minor_divisions = divisions;
627        self
628    }
629
630    /// Enable extension beyond the plot area.
631    pub fn extend_beyond(mut self) -> Self {
632        self.extend_beyond_plot = true;
633        self
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn test_dash_pattern_cycle_length() {
643        let dashed = DashPattern::dashed(10.0, 5.0);
644        assert_eq!(dashed.cycle_length(), 15.0);
645
646        let solid = DashPattern::SOLID;
647        assert_eq!(solid.cycle_length(), 0.0);
648        assert!(solid.is_solid());
649    }
650
651    #[test]
652    fn test_grid_spacing_auto() {
653        let spacing = GridSpacing::auto(5);
654        let (major, _minor) = spacing.calculate_positions(0.0, 100.0, 2);
655
656        assert!(!major.is_empty());
657        // Should have "nice" intervals
658        for &pos in &major {
659            // Should be at nice positions like 0, 20, 40, 60, 80, 100
660            assert!((0.0..=100.0).contains(&pos));
661        }
662    }
663
664    #[test]
665    fn test_grid_spacing_fixed() {
666        let spacing = GridSpacing::fixed(10.0);
667        let (major, _) = spacing.calculate_positions(0.0, 50.0, 1);
668
669        assert_eq!(major, vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0]);
670    }
671
672    #[test]
673    fn test_grid_spacing_log_decades() {
674        let spacing = GridSpacing::log_decades(2);
675        let (major, minor) = spacing.calculate_positions(1.0, 1000.0, 1);
676
677        assert!(major.contains(&1.0));
678        assert!(major.contains(&10.0));
679        assert!(major.contains(&100.0));
680        assert!(major.contains(&1000.0));
681        assert!(!minor.is_empty());
682    }
683
684    #[test]
685    fn test_grid_config_presets() {
686        let minimal = GridConfig::minimal();
687        assert!(minimal.major.enabled);
688        assert!(minimal.minor.is_none());
689
690        let detailed = GridConfig::detailed();
691        assert!(detailed.major.enabled);
692        assert!(detailed.minor.is_some());
693        assert!(detailed.minor.as_ref().unwrap().enabled);
694    }
695}