Skip to main content

astrelis_geometry/chart/
axis.rs

1//! Enhanced axis system with multiple scale types and axis linking.
2//!
3//! This module provides:
4//! - `ScaleType` - Linear, logarithmic, symmetric log, and time scales
5//! - `AxisLink` - Linking axes for synchronized pan/zoom
6//! - Extended `AxisPosition` with custom positioning
7//! - `TickConfig` - Fine-grained tick configuration
8
9use super::style::AxisStyle;
10use super::types::{AxisId, AxisOrientation, AxisPosition};
11
12/// Time epoch for time-based scales.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum TimeEpoch {
15    /// Unix epoch (1970-01-01 00:00:00 UTC)
16    #[default]
17    Unix,
18    /// J2000 epoch (2000-01-01 12:00:00 TT)
19    J2000,
20    /// Custom epoch (offset in seconds from Unix epoch)
21    Custom(i64),
22}
23
24/// Scale type for axis transformation.
25///
26/// Determines how data values are mapped to pixel coordinates.
27#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub enum ScaleType {
29    /// Linear scale (default).
30    ///
31    /// Data values are mapped linearly to pixel coordinates.
32    #[default]
33    Linear,
34
35    /// Logarithmic scale.
36    ///
37    /// Useful for data spanning multiple orders of magnitude.
38    /// Data must be > 0.
39    Logarithmic {
40        /// Log base (typically 10 or e)
41        base: f64,
42    },
43
44    /// Symmetric logarithmic scale.
45    ///
46    /// Like log scale but handles negative values and zero.
47    /// Uses linear scale near zero and log scale for larger values.
48    Symlog {
49        /// Threshold below which linear scaling is used
50        lin_threshold: f64,
51    },
52
53    /// Time-based scale.
54    ///
55    /// Data values are interpreted as timestamps.
56    Time {
57        /// Time epoch for interpretation
58        epoch: TimeEpoch,
59    },
60}
61
62impl ScaleType {
63    /// Create a base-10 logarithmic scale.
64    pub fn log10() -> Self {
65        Self::Logarithmic { base: 10.0 }
66    }
67
68    /// Create a natural logarithmic scale.
69    pub fn ln() -> Self {
70        Self::Logarithmic {
71            base: std::f64::consts::E,
72        }
73    }
74
75    /// Create a symmetric log scale with the given threshold.
76    pub fn symlog(threshold: f64) -> Self {
77        Self::Symlog {
78            lin_threshold: threshold,
79        }
80    }
81
82    /// Create a time scale with Unix epoch.
83    pub fn time() -> Self {
84        Self::Time {
85            epoch: TimeEpoch::Unix,
86        }
87    }
88
89    /// Transform a data value to normalized coordinates [0, 1].
90    ///
91    /// Given a value in the range [min, max], returns a normalized value.
92    pub fn normalize(&self, value: f64, min: f64, max: f64) -> f64 {
93        if (max - min).abs() < f64::EPSILON {
94            return 0.5;
95        }
96
97        match self {
98            Self::Linear | Self::Time { .. } => (value - min) / (max - min),
99
100            Self::Logarithmic { base } => {
101                if value <= 0.0 || min <= 0.0 || max <= 0.0 {
102                    // Fall back to linear for invalid log values
103                    return (value - min) / (max - min);
104                }
105                let log_value = value.log(*base);
106                let log_min = min.log(*base);
107                let log_max = max.log(*base);
108                (log_value - log_min) / (log_max - log_min)
109            }
110
111            Self::Symlog { lin_threshold } => {
112                let symlog = |x: f64| -> f64 {
113                    let thresh = *lin_threshold;
114                    if x.abs() < thresh {
115                        x / thresh
116                    } else {
117                        x.signum() * (1.0 + (x.abs() / thresh).ln())
118                    }
119                };
120
121                let sym_value = symlog(value);
122                let sym_min = symlog(min);
123                let sym_max = symlog(max);
124                (sym_value - sym_min) / (sym_max - sym_min)
125            }
126        }
127    }
128
129    /// Transform a normalized coordinate back to data value.
130    ///
131    /// Inverse of `normalize`.
132    pub fn denormalize(&self, normalized: f64, min: f64, max: f64) -> f64 {
133        match self {
134            Self::Linear | Self::Time { .. } => min + normalized * (max - min),
135
136            Self::Logarithmic { base } => {
137                if min <= 0.0 || max <= 0.0 {
138                    return min + normalized * (max - min);
139                }
140                let log_min = min.log(*base);
141                let log_max = max.log(*base);
142                let log_value = log_min + normalized * (log_max - log_min);
143                base.powf(log_value)
144            }
145
146            Self::Symlog { lin_threshold } => {
147                let thresh = *lin_threshold;
148
149                let symlog = |x: f64| -> f64 {
150                    if x.abs() < thresh {
151                        x / thresh
152                    } else {
153                        x.signum() * (1.0 + (x.abs() / thresh).ln())
154                    }
155                };
156
157                let inv_symlog = |y: f64| -> f64 {
158                    if y.abs() < 1.0 {
159                        y * thresh
160                    } else {
161                        y.signum() * thresh * (y.abs() - 1.0).exp()
162                    }
163                };
164
165                let sym_min = symlog(min);
166                let sym_max = symlog(max);
167                let sym_value = sym_min + normalized * (sym_max - sym_min);
168                inv_symlog(sym_value)
169            }
170        }
171    }
172}
173
174/// Axis linking configuration for synchronized pan/zoom.
175///
176/// Multiple axes can be linked together so that panning or zooming
177/// one axis affects all linked axes.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
179pub struct AxisLink {
180    /// Pan synchronization group.
181    ///
182    /// Axes with the same pan group will pan together.
183    /// `None` means no pan linking.
184    pub pan_group: Option<u32>,
185
186    /// Zoom synchronization group.
187    ///
188    /// Axes with the same zoom group will zoom together.
189    /// `None` means no zoom linking.
190    pub zoom_group: Option<u32>,
191
192    /// Whether this axis is inverted relative to its link group.
193    ///
194    /// When `true`, pan/zoom operations are applied in reverse.
195    pub inverted: bool,
196}
197
198impl AxisLink {
199    /// Create an unlinked axis.
200    pub fn none() -> Self {
201        Self::default()
202    }
203
204    /// Link both pan and zoom to the specified group.
205    pub fn linked(group: u32) -> Self {
206        Self {
207            pan_group: Some(group),
208            zoom_group: Some(group),
209            inverted: false,
210        }
211    }
212
213    /// Link only pan to the specified group.
214    pub fn pan_only(group: u32) -> Self {
215        Self {
216            pan_group: Some(group),
217            zoom_group: None,
218            inverted: false,
219        }
220    }
221
222    /// Link only zoom to the specified group.
223    pub fn zoom_only(group: u32) -> Self {
224        Self {
225            pan_group: None,
226            zoom_group: Some(group),
227            inverted: false,
228        }
229    }
230
231    /// Set this axis as inverted relative to its link group.
232    pub fn inverted(mut self) -> Self {
233        self.inverted = true;
234        self
235    }
236}
237
238/// Extended axis position supporting custom positioning.
239#[derive(Debug, Clone, Copy, PartialEq)]
240pub enum ExtendedAxisPosition {
241    /// Standard position (left, right, top, bottom).
242    Standard(AxisPosition),
243
244    /// Position at a specific data value.
245    ///
246    /// The axis line will be drawn at this data coordinate.
247    AtValue(f64),
248
249    /// Position at a percentage of the plot area.
250    ///
251    /// 0.0 = left/top edge, 1.0 = right/bottom edge.
252    AtPercent(f32),
253}
254
255impl Default for ExtendedAxisPosition {
256    fn default() -> Self {
257        Self::Standard(AxisPosition::Left)
258    }
259}
260
261impl From<AxisPosition> for ExtendedAxisPosition {
262    fn from(pos: AxisPosition) -> Self {
263        Self::Standard(pos)
264    }
265}
266
267impl ExtendedAxisPosition {
268    /// Check if this is a standard position.
269    pub fn is_standard(&self) -> bool {
270        matches!(self, Self::Standard(_))
271    }
272
273    /// Get the standard position if this is one.
274    pub fn standard(&self) -> Option<AxisPosition> {
275        match self {
276            Self::Standard(pos) => Some(*pos),
277            _ => None,
278        }
279    }
280}
281
282/// Tick configuration for axis labels.
283#[derive(Debug, Clone)]
284pub struct TickConfig {
285    /// Target number of major ticks (auto-calculated if None).
286    pub major_count: Option<usize>,
287
288    /// Number of minor ticks between major ticks.
289    pub minor_count: usize,
290
291    /// Whether to show tick labels.
292    pub show_labels: bool,
293
294    /// Tick label format string (printf-style for numbers).
295    /// `None` uses automatic formatting.
296    pub label_format: Option<String>,
297
298    /// Custom tick values and labels (overrides auto ticks).
299    pub custom_ticks: Option<Vec<(f64, String)>>,
300
301    /// Rotation angle for tick labels (in degrees).
302    pub label_rotation: f32,
303
304    /// Whether ticks point inward (into the plot area).
305    pub ticks_inward: bool,
306}
307
308impl Default for TickConfig {
309    fn default() -> Self {
310        Self {
311            major_count: Some(5),
312            minor_count: 0,
313            show_labels: true,
314            label_format: None,
315            custom_ticks: None,
316            label_rotation: 0.0,
317            ticks_inward: false,
318        }
319    }
320}
321
322impl TickConfig {
323    /// Create tick config with the specified major tick count.
324    pub fn with_count(count: usize) -> Self {
325        Self {
326            major_count: Some(count),
327            ..Default::default()
328        }
329    }
330
331    /// Create tick config with custom ticks.
332    pub fn custom(ticks: Vec<(f64, String)>) -> Self {
333        Self {
334            custom_ticks: Some(ticks),
335            ..Default::default()
336        }
337    }
338
339    /// Set minor tick count.
340    pub fn minor(mut self, count: usize) -> Self {
341        self.minor_count = count;
342        self
343    }
344
345    /// Hide tick labels.
346    pub fn no_labels(mut self) -> Self {
347        self.show_labels = false;
348        self
349    }
350
351    /// Set label format string.
352    pub fn format(mut self, fmt: impl Into<String>) -> Self {
353        self.label_format = Some(fmt.into());
354        self
355    }
356
357    /// Set label rotation.
358    pub fn rotated(mut self, degrees: f32) -> Self {
359        self.label_rotation = degrees;
360        self
361    }
362
363    /// Set ticks to point inward.
364    pub fn inward(mut self) -> Self {
365        self.ticks_inward = true;
366        self
367    }
368}
369
370/// Enhanced axis configuration with all options.
371#[derive(Debug, Clone)]
372pub struct EnhancedAxis {
373    /// Unique identifier.
374    pub id: AxisId,
375
376    /// Optional name for the axis (used for lookup by name).
377    pub name: Option<String>,
378
379    /// Axis label displayed alongside the axis.
380    pub label: Option<String>,
381
382    /// Axis orientation (horizontal or vertical).
383    pub orientation: AxisOrientation,
384
385    /// Position of the axis.
386    pub position: ExtendedAxisPosition,
387
388    /// Offset from the standard position (for stacked axes).
389    pub position_offset: f32,
390
391    /// Minimum value (None = auto from data).
392    pub min: Option<f64>,
393
394    /// Maximum value (None = auto from data).
395    pub max: Option<f64>,
396
397    /// Scale type for value transformation.
398    pub scale: ScaleType,
399
400    /// Tick configuration.
401    pub ticks: TickConfig,
402
403    /// Visual style.
404    pub style: AxisStyle,
405
406    /// Whether the axis is visible.
407    pub visible: bool,
408
409    /// Axis linking configuration.
410    pub link: AxisLink,
411
412    /// Whether to auto-range based on data.
413    pub auto_range: bool,
414
415    /// Padding to add around auto-ranged data (as a fraction).
416    pub range_padding: f64,
417}
418
419impl Default for EnhancedAxis {
420    fn default() -> Self {
421        Self {
422            id: AxisId::default(),
423            name: None,
424            label: None,
425            orientation: AxisOrientation::Vertical,
426            position: ExtendedAxisPosition::Standard(AxisPosition::Left),
427            position_offset: 0.0,
428            min: None,
429            max: None,
430            scale: ScaleType::Linear,
431            ticks: TickConfig::default(),
432            style: AxisStyle::default(),
433            visible: true,
434            link: AxisLink::none(),
435            auto_range: true,
436            range_padding: 0.05,
437        }
438    }
439}
440
441impl EnhancedAxis {
442    /// Create a new primary X axis.
443    pub fn x() -> Self {
444        Self {
445            id: AxisId::X_PRIMARY,
446            orientation: AxisOrientation::Horizontal,
447            position: ExtendedAxisPosition::Standard(AxisPosition::Bottom),
448            ..Default::default()
449        }
450    }
451
452    /// Create a new primary Y axis.
453    pub fn y() -> Self {
454        Self {
455            id: AxisId::Y_PRIMARY,
456            orientation: AxisOrientation::Vertical,
457            position: ExtendedAxisPosition::Standard(AxisPosition::Left),
458            ..Default::default()
459        }
460    }
461
462    /// Create a secondary X axis (top).
463    pub fn x_secondary() -> Self {
464        Self {
465            id: AxisId::X_SECONDARY,
466            orientation: AxisOrientation::Horizontal,
467            position: ExtendedAxisPosition::Standard(AxisPosition::Top),
468            ..Default::default()
469        }
470    }
471
472    /// Create a secondary Y axis (right).
473    pub fn y_secondary() -> Self {
474        Self {
475            id: AxisId::Y_SECONDARY,
476            orientation: AxisOrientation::Vertical,
477            position: ExtendedAxisPosition::Standard(AxisPosition::Right),
478            ..Default::default()
479        }
480    }
481
482    /// Create a custom axis with the specified ID and name.
483    pub fn custom(id: u32, name: impl Into<String>) -> Self {
484        Self {
485            id: AxisId::custom(id),
486            name: Some(name.into()),
487            ..Default::default()
488        }
489    }
490
491    /// Set the axis label.
492    pub fn with_label(mut self, label: impl Into<String>) -> Self {
493        self.label = Some(label.into());
494        self
495    }
496
497    /// Set the axis range.
498    pub fn with_range(mut self, min: f64, max: f64) -> Self {
499        self.min = Some(min);
500        self.max = Some(max);
501        self.auto_range = false;
502        self
503    }
504
505    /// Set the scale type.
506    pub fn with_scale(mut self, scale: ScaleType) -> Self {
507        self.scale = scale;
508        self
509    }
510
511    /// Set the position.
512    pub fn with_position(mut self, position: impl Into<ExtendedAxisPosition>) -> Self {
513        self.position = position.into();
514        self
515    }
516
517    /// Set the position offset.
518    pub fn with_offset(mut self, offset: f32) -> Self {
519        self.position_offset = offset;
520        self
521    }
522
523    /// Set the tick configuration.
524    pub fn with_ticks(mut self, ticks: TickConfig) -> Self {
525        self.ticks = ticks;
526        self
527    }
528
529    /// Set tick count.
530    pub fn with_tick_count(mut self, count: usize) -> Self {
531        self.ticks.major_count = Some(count);
532        self
533    }
534
535    /// Set the style.
536    pub fn with_style(mut self, style: AxisStyle) -> Self {
537        self.style = style;
538        self
539    }
540
541    /// Set visibility.
542    pub fn with_visible(mut self, visible: bool) -> Self {
543        self.visible = visible;
544        self
545    }
546
547    /// Set axis linking.
548    pub fn with_link(mut self, link: AxisLink) -> Self {
549        self.link = link;
550        self
551    }
552
553    /// Enable auto-ranging with the specified padding.
554    pub fn auto_ranged(mut self, padding: f64) -> Self {
555        self.auto_range = true;
556        self.range_padding = padding;
557        self
558    }
559
560    /// Get the effective range for this axis.
561    ///
562    /// If auto_range is enabled and data bounds are provided,
563    /// returns the data bounds with padding applied.
564    pub fn effective_range(&self, data_bounds: Option<(f64, f64)>) -> (f64, f64) {
565        match (self.min, self.max) {
566            (Some(min), Some(max)) => (min, max),
567            (Some(min), None) => {
568                let max = data_bounds.map(|(_, max)| max).unwrap_or(1.0);
569                let padded_max = if self.auto_range {
570                    max + (max - min).abs() * self.range_padding
571                } else {
572                    max
573                };
574                (min, padded_max)
575            }
576            (None, Some(max)) => {
577                let min = data_bounds.map(|(min, _)| min).unwrap_or(0.0);
578                let padded_min = if self.auto_range {
579                    min - (max - min).abs() * self.range_padding
580                } else {
581                    min
582                };
583                (padded_min, max)
584            }
585            (None, None) => {
586                let (min, max) = data_bounds.unwrap_or((0.0, 1.0));
587                if self.auto_range {
588                    let range = (max - min).abs();
589                    let padding = if range < f64::EPSILON {
590                        0.5 // Default padding for zero range
591                    } else {
592                        range * self.range_padding
593                    };
594                    (min - padding, max + padding)
595                } else {
596                    (min, max)
597                }
598            }
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn test_linear_scale() {
609        let scale = ScaleType::Linear;
610
611        assert!((scale.normalize(50.0, 0.0, 100.0) - 0.5).abs() < 0.001);
612        assert!((scale.denormalize(0.5, 0.0, 100.0) - 50.0).abs() < 0.001);
613    }
614
615    #[test]
616    fn test_log_scale() {
617        let scale = ScaleType::log10();
618
619        // 10 is at 50% between 1 and 100 on a log scale
620        let normalized = scale.normalize(10.0, 1.0, 100.0);
621        assert!((normalized - 0.5).abs() < 0.001);
622
623        let denormalized = scale.denormalize(0.5, 1.0, 100.0);
624        assert!((denormalized - 10.0).abs() < 0.001);
625    }
626
627    #[test]
628    fn test_symlog_scale() {
629        let scale = ScaleType::symlog(1.0);
630
631        // Should handle zero and negative values
632        let norm_zero = scale.normalize(0.0, -10.0, 10.0);
633        assert!((norm_zero - 0.5).abs() < 0.001);
634
635        // Round trip
636        let value = -5.0;
637        let normalized = scale.normalize(value, -10.0, 10.0);
638        let denormalized = scale.denormalize(normalized, -10.0, 10.0);
639        assert!((denormalized - value).abs() < 0.001);
640    }
641
642    #[test]
643    fn test_axis_effective_range() {
644        let axis = EnhancedAxis::y().auto_ranged(0.1);
645
646        let range = axis.effective_range(Some((0.0, 100.0)));
647        assert!(range.0 < 0.0); // Should have negative padding
648        assert!(range.1 > 100.0); // Should have positive padding
649    }
650
651    #[test]
652    fn test_axis_link() {
653        let link = AxisLink::linked(1).inverted();
654
655        assert_eq!(link.pan_group, Some(1));
656        assert_eq!(link.zoom_group, Some(1));
657        assert!(link.inverted);
658    }
659}