Skip to main content

model/
format.rs

1use crate::config::{AxisOptions, AxisScale, Config, LabelStyle};
2
3#[derive(Debug, Clone, PartialEq)]
4#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
5pub enum LabelFormat {
6    /// Plain decimal: `1234.56`, `0.003`, `1000`, `10`.
7    Decimal,
8    /// ASCII scientific: `1.23e3`, `1e-1`.
9    Scientific,
10    /// Typographic superscript exponent (RichText): `10³`, `1.5×10⁻³`, `10⁻¹`.
11    Power,
12    /// Unix-epoch timestamp labels. Data values stay numeric; this only
13    /// changes tick planning and label text on linear axes.
14    Timestamp(TimestampLabelFormat),
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub enum TimestampUnit {
20    #[default]
21    Seconds,
22    Milliseconds,
23    Microseconds,
24    Nanoseconds,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum TimestampZone {
30    #[default]
31    Utc,
32    /// Fixed offset from UTC in minutes. Korea Standard Time is 540.
33    FixedOffsetMinutes(i32),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub enum TimestampLabelMode {
39    #[default]
40    Auto,
41    /// Minimal strftime-like pattern: %Y %m %d %H %M %S %f and %%.
42    Pattern(String),
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47pub enum FractionalSecondDigits {
48    #[default]
49    Auto,
50    Fixed(u8),
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
55pub enum TimestampTickPolicy {
56    /// Calendar-aware ticks such as minutes, hours, days, months, and years.
57    #[default]
58    AutoCalendar,
59    /// Reuse the numeric `major_spacing` / `minor_count` tick walkers.
60    NumericSpacing,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65#[cfg_attr(feature = "serde", serde(default))]
66pub struct TimestampLabelFormat {
67    pub unit: TimestampUnit,
68    pub timezone: TimestampZone,
69    pub label: TimestampLabelMode,
70    pub fractional: FractionalSecondDigits,
71    pub tick_policy: TimestampTickPolicy,
72}
73
74fn auto_sig_linear(min: f64, max: f64, step: f64) -> u8 {
75    let max_abs = f64::max(min.abs(), max.abs());
76    if max_abs == 0.0 || step <= 0.0 {
77        return 1;
78    }
79    let order_max = max_abs.log10().floor() as i32;
80    let order_step = step.log10().floor() as i32;
81    // step >= 1: integer-range ticks → total integer digits.
82    // step <  1: fractional range → integer digits + fractional digits.
83    let sig = if step >= 1.0 {
84        order_max + 1
85    } else {
86        order_max - order_step + 1
87    };
88    sig.clamp(1, 15) as u8
89}
90
91fn auto_sig_log(_min: f64, _max: f64, _major_spacing: f64) -> u8 {
92    2
93}
94
95impl LabelStyle {
96    pub fn compute_auto_significant_digits(
97        scale: AxisScale,
98        min: f64,
99        max: f64,
100        major_spacing: f64,
101    ) -> u8 {
102        match scale {
103            AxisScale::Linear => auto_sig_linear(min, max, major_spacing),
104            AxisScale::Logarithmic => auto_sig_log(min, max, major_spacing),
105        }
106    }
107}
108
109impl AxisOptions {
110    pub fn auto_significant_digits(&mut self) {
111        let sig = LabelStyle::compute_auto_significant_digits(
112            self.scale.clone(),
113            self.min,
114            self.max,
115            self.major_spacing,
116        );
117        self.label_style.significant_digits = sig.max(1);
118    }
119}
120
121impl Config {
122    pub fn auto_significant_digits_all(&mut self) {
123        self.top_x.auto_significant_digits();
124        self.bottom_x.auto_significant_digits();
125        self.left_y.auto_significant_digits();
126        self.right_y.auto_significant_digits();
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::default::default_config;
134
135    // Result must be in 1..=15.
136    #[test]
137    fn sig_digits_bounds() {
138        for (min, max, step) in [
139            (0.0, 1.0, 0.2),
140            (0.0, 1000.0, 200.0),
141            (0.0, 0.01, 0.002),
142            (-5.0, 5.0, 1.0),
143            (100.0, 110.0, 2.0),
144            (0.0, 0.0, 0.0),
145        ] {
146            let s = LabelStyle::compute_auto_significant_digits(AxisScale::Linear, min, max, step);
147            assert!((1..=15).contains(&s), "sig out of bounds: {}", s);
148        }
149    }
150
151    #[test]
152    fn sig_digits_spec_examples() {
153        let cases: &[(f64, f64, f64, u8)] = &[
154            (0.0, 1.0, 0.2, 2),
155            (0.0, 1000.0, 200.0, 4),
156            (0.0, 0.01, 0.002, 2),
157            (-5.0, 5.0, 1.0, 1),
158            (100.0, 110.0, 2.0, 3),
159        ];
160        for &(min, max, step, expected) in cases {
161            let got =
162                LabelStyle::compute_auto_significant_digits(AxisScale::Linear, min, max, step);
163            assert_eq!(got, expected, "min={}, max={}, step={}", min, max, step);
164        }
165    }
166
167    #[test]
168    fn log_sig_digits_default_2() {
169        let s =
170            LabelStyle::compute_auto_significant_digits(AxisScale::Logarithmic, 0.001, 1000.0, 1.0);
171        assert_eq!(s, 2);
172    }
173
174    #[cfg(feature = "serde")]
175    #[test]
176    fn timestamp_format_serde_roundtrip() {
177        let fmt = LabelFormat::Timestamp(TimestampLabelFormat {
178            unit: TimestampUnit::Milliseconds,
179            timezone: TimestampZone::FixedOffsetMinutes(540),
180            label: TimestampLabelMode::Pattern("%Y-%m-%d %H:%M:%S.%f".into()),
181            fractional: FractionalSecondDigits::Fixed(3),
182            tick_policy: TimestampTickPolicy::NumericSpacing,
183        });
184        let json = serde_json::to_string(&fmt).unwrap();
185        let got: LabelFormat = serde_json::from_str(&json).unwrap();
186        assert_eq!(got, fmt);
187    }
188
189    // auto_significant_digits should mutate only the sig field.
190    #[test]
191    fn auto_sig_changes_only_sig_field() {
192        let mut cfg = default_config();
193        let before = cfg.clone();
194        cfg.top_x.auto_significant_digits();
195        let mut expected = before.clone();
196        expected.top_x.label_style.significant_digits = LabelStyle::compute_auto_significant_digits(
197            AxisScale::Linear,
198            expected.top_x.min,
199            expected.top_x.max,
200            expected.top_x.major_spacing,
201        );
202        assert_eq!(cfg, expected);
203    }
204
205    // auto_significant_digits_all should also mutate only sig fields.
206    #[test]
207    fn auto_sig_all_changes_only_sig_fields() {
208        let mut cfg = default_config();
209        let before = cfg.clone();
210        cfg.auto_significant_digits_all();
211        assert!(cfg.top_x.label_style.significant_digits >= 1);
212        assert!(cfg.bottom_x.label_style.significant_digits >= 1);
213        assert!(cfg.left_y.label_style.significant_digits >= 1);
214        assert!(cfg.right_y.label_style.significant_digits >= 1);
215        assert_eq!(cfg.chart_area, before.chart_area);
216        assert_eq!(cfg.grid, before.grid);
217        assert_eq!(cfg.chart_title, before.chart_title);
218    }
219}