Skip to main content

egui_charts/scales/
horizontal.rs

1//! Horizontal scales: what the horizontal position of a bar stands for and how it is labelled.
2//!
3//! Time charts give each bar one ordinal slot. Numeric scales place keys in proportion to
4//! their values, without placeholder bars. The host encodes each numeric key with [`key_time`];
5//! [`PriceHorizontalScale`] and [`MaturityScale`] label the axis and crosshair from it.
6
7use super::time_formatter::TimeFormatter;
8use super::timescale_marks::{
9    TickMark, TickMarkGenerator, TickMarkGeneratorConfig, TickMarkType, TickMarkWeight,
10    keep_spaced_by,
11};
12use crate::model::Bar;
13use chrono::{DateTime, Duration, Utc};
14
15/// Where the tick marks of a horizontal axis are laid out.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct TickMarkLayout {
18    /// Plot width in points.
19    pub width: f32,
20    /// Points per ordinal slot, or per numeric key unit on a numeric scale.
21    pub bar_spacing: f32,
22    /// Minimum distance between the centres of two labels in points.
23    pub min_spacing: f32,
24    /// Whether marks of one weight are shown all or none, so labels of one kind stay evenly
25    /// spaced.
26    pub uniform_distribution: bool,
27}
28
29/// What the keys of the horizontal axis mean: which of them are labelled, how, and how the
30/// crosshair label reads.
31///
32/// A key is the [`time`](crate::model::Bar::time) of a bar. The time-based chart uses its
33/// [`TimeFormatter`]; `Chart::set_horizontal_scale` (requires `widget`)
34/// replaces it.
35pub trait HorizontalScale: Send + Sync {
36    /// Whether positions are proportional to numeric keys encoded with [`key_time`].
37    /// The default retains ordinal time slots. Numeric scales use key units for
38    /// [`TickMarkLayout::bar_spacing`].
39    fn is_numeric(&self) -> bool {
40        false
41    }
42
43    /// The marks to label among `points`, the key and logical index of each bar in view in
44    /// order, extended into whitespace on both sides using the chart's full-history time
45    /// mapping. When the keys reach before the first bar, every index is shifted by the same
46    /// number of slots, so only the order of the indices and their differences carry meaning.
47    /// Numeric scales receive the same bounded whitespace extension as time scales.
48    /// The renderer places each mark at its key and skips labels that would overlap.
49    fn tick_marks(
50        &self,
51        points: &[(DateTime<Utc>, usize)],
52        layout: &TickMarkLayout,
53    ) -> Vec<TickMark>;
54
55    /// The crosshair label of `key`. `bar_seconds` is the duration of the bars in view, which
56    /// the time scale uses for the precision of the clock.
57    fn format_crosshair(&self, key: DateTime<Utc>, bar_seconds: i64) -> String;
58}
59
60/// The time scale: calendar and clock boundaries in the formatter's zone.
61impl HorizontalScale for Box<dyn TimeFormatter> {
62    fn tick_marks(
63        &self,
64        points: &[(DateTime<Utc>, usize)],
65        layout: &TickMarkLayout,
66    ) -> Vec<TickMark> {
67        let (Some(first), Some(last)) = (points.first(), points.last()) else {
68            return Vec::new();
69        };
70        // The median controls clock precision; point timestamps already carry the chart mapping.
71        let bar_duration = median_bar_duration(points.iter().map(|point| point.0));
72        let zoom_factor = (layout.bar_spacing / 8.0).clamp(0.5, 2.5);
73        let config = TickMarkGeneratorConfig {
74            min_spacing: layout.min_spacing,
75            max_marks: ((layout.width / layout.min_spacing).ceil() as usize + 2).clamp(6, 24),
76            show_subseconds: bar_duration < Duration::seconds(1),
77            use_24_hour: true,
78            // The bar span already accounts for zoom; keep clock labels at a steady density.
79            target_density: if bar_duration < Duration::days(1) {
80                1.0
81            } else {
82                (1.0 * zoom_factor).clamp(0.6, 2.5)
83            },
84        };
85        TickMarkGenerator::with_formatter(config, (**self).clone_box())
86            .with_uniform_distribution(layout.uniform_distribution)
87            .generate_marks(first.0, last.0, layout.width, points)
88    }
89
90    fn format_crosshair(&self, key: DateTime<Utc>, bar_seconds: i64) -> String {
91        (**self).format_crosshair(key, bar_seconds)
92    }
93}
94
95/// The median positive gap between successive times, ignoring repeated keys and defaulting
96/// to a minute when there is no gap, as the time axis does for a single key.
97pub(crate) fn median_bar_duration(times: impl Iterator<Item = DateTime<Utc>>) -> Duration {
98    let mut previous = None;
99    let mut deltas_ms: Vec<_> = times
100        .filter_map(|time| {
101            let delta = (time - previous.replace(time)?).num_milliseconds().abs();
102            (delta > 0).then_some(delta)
103        })
104        .collect();
105    deltas_ms.sort_unstable();
106    Duration::milliseconds(
107        deltas_ms
108            .get(deltas_ms.len() / 2)
109            .copied()
110            .unwrap_or(60_000),
111    )
112}
113
114/// The bar time standing for a numeric key on a horizontal scale other than time: the key as
115/// seconds from the Unix epoch, to the nanosecond, so times sort as their keys do.
116///
117/// `None` for a key that is not finite or lies beyond about ±9.2e9.
118pub fn key_time(key: f64) -> Option<DateTime<Utc>> {
119    let nanos = (key * 1e9).round();
120    (nanos.abs() < i64::MAX as f64).then(|| DateTime::from_timestamp_nanos(nanos as i64))
121}
122
123/// The numeric key a bar time stands for, the inverse of [`key_time`].
124pub fn time_key(time: DateTime<Utc>) -> f64 {
125    time.timestamp() as f64 + f64::from(time.timestamp_subsec_nanos()) / 1e9
126}
127
128/// A horizontal scale of prices, such as the strikes of an option chain, encoded with
129/// [`key_time`].
130///
131/// The axis labels the roundest prices in view first: multiples of 100, then of 50, 25, 10, 5
132/// and 1, then half prices, and draws the roundest kind shown in bold. Labels and the crosshair
133/// show `precision` decimals.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct PriceHorizontalScale {
136    /// Decimals of the labels.
137    pub precision: usize,
138}
139
140impl Default for PriceHorizontalScale {
141    fn default() -> Self {
142        Self { precision: 2 }
143    }
144}
145
146impl HorizontalScale for PriceHorizontalScale {
147    fn is_numeric(&self) -> bool {
148        true
149    }
150
151    fn tick_marks(
152        &self,
153        points: &[(DateTime<Utc>, usize)],
154        layout: &TickMarkLayout,
155    ) -> Vec<TickMark> {
156        let weight = |price: f64| {
157            [
158                (100.0, 8),
159                (50.0, 7),
160                (25.0, 6),
161                (10.0, 5),
162                (5.0, 4),
163                (1.0, 3),
164                (0.5, 1),
165            ]
166            .into_iter()
167            .find(|(step, _)| price % step == 0.0)
168            .map_or(0, |(_, weight)| weight)
169        };
170        weighted_marks(points, layout, weight, |price| {
171            format!("{price:.*}", self.precision)
172        })
173    }
174
175    fn format_crosshair(&self, key: DateTime<Utc>, _bar_seconds: i64) -> String {
176        format!("{:.*}", self.precision, time_key(key))
177    }
178}
179
180/// A horizontal scale of maturities in months, for yield curves, encoded with [`key_time`].
181///
182/// Maturities read `3M` below a year and `2Y` or `2Y6M` from a year on. The axis labels
183/// multiples of 10 years first, then of 5 and 3 years, whole years, half years, quarters and
184/// months, and draws the longest kind shown in bold.
185///
186/// Actual maturity samples are spaced linearly by month. [`Self::whitespace`] remains available
187/// for hosts that also need explicit empty samples.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct MaturityScale {
190    /// Months between neighbouring slots.
191    pub base_resolution: u32,
192    /// Months the whitespace spans at least, from `start`.
193    pub minimum_range: u32,
194    /// Maturity of the first slot in months.
195    pub start: u32,
196}
197
198impl Default for MaturityScale {
199    /// Monthly slots over the first ten years.
200    fn default() -> Self {
201        Self {
202            base_resolution: 1,
203            minimum_range: 120,
204            start: 0,
205        }
206    }
207}
208
209impl MaturityScale {
210    /// Whitespace bars every `base_resolution` months from `start` up to `last`, the longest
211    /// maturity on the chart, or up to `minimum_range` when that is later.
212    /// Non-finite `last` values fall back to `minimum_range`; finite values are capped at
213    /// 12,000 months (1,000 years) before comparison, without limiting `minimum_range`.
214    pub fn whitespace(&self, last: f64) -> Vec<Bar> {
215        let step = f64::from(self.base_resolution.max(1));
216        let minimum = f64::from(self.minimum_range);
217        let end = if last.is_finite() {
218            minimum.max(last.min(12_000.0))
219        } else {
220            minimum
221        };
222        (0..)
223            .map(|slot| f64::from(self.start) + f64::from(slot) * step)
224            .take_while(|months| *months <= end)
225            .filter_map(key_time)
226            .map(Bar::whitespace)
227            .collect()
228    }
229
230    fn format(months: f64) -> String {
231        let (years, rest) = ((months / 12.0).floor(), months % 12.0);
232        if months < 12.0 {
233            format!("{months}M")
234        } else if rest == 0.0 {
235            format!("{years}Y")
236        } else {
237            format!("{years}Y{rest}M")
238        }
239    }
240}
241
242impl HorizontalScale for MaturityScale {
243    fn is_numeric(&self) -> bool {
244        true
245    }
246
247    fn tick_marks(
248        &self,
249        points: &[(DateTime<Utc>, usize)],
250        layout: &TickMarkLayout,
251    ) -> Vec<TickMark> {
252        let weight = |months: f64| {
253            [
254                (120.0, 10),
255                (60.0, 9),
256                (36.0, 8),
257                (12.0, 7),
258                (6.0, 6),
259                (3.0, 5),
260                (1.0, 4),
261            ]
262            .into_iter()
263            .find(|(step, _)| months % step == 0.0)
264            .map_or(0, |(_, weight)| weight)
265        };
266        weighted_marks(points, layout, weight, Self::format)
267    }
268
269    fn format_crosshair(&self, key: DateTime<Utc>, _bar_seconds: i64) -> String {
270        Self::format(time_key(key))
271    }
272}
273
274/// Marks every point with the `weight` of its numeric key and keeps the heaviest marks that lie
275/// the minimum numeric distance apart, as the time scale does for ordinal slots. The heaviest
276/// kept marks are bold.
277fn weighted_marks(
278    points: &[(DateTime<Utc>, usize)],
279    layout: &TickMarkLayout,
280    weight: impl Fn(f64) -> u8,
281    label: impl Fn(f64) -> String,
282) -> Vec<TickMark> {
283    let marks = points
284        .iter()
285        .map(|&(time, index)| TickMark {
286            time,
287            mark_type: TickMarkType::Time,
288            weight: TickMarkWeight::new(weight(time_key(time))),
289            label: String::new(),
290            index,
291            bold: false,
292        })
293        .collect();
294    let mut kept = keep_spaced_by(
295        marks,
296        f64::from(layout.min_spacing / layout.bar_spacing),
297        layout.uniform_distribution,
298        |mark| time_key(mark.time),
299    );
300    let heaviest = kept.iter().map(|mark| mark.weight).max();
301    for mark in &mut kept {
302        mark.bold = Some(mark.weight) == heaviest;
303        mark.label = label(time_key(mark.time));
304    }
305    kept.sort_by_key(|mark| mark.index);
306    kept
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    /// Expected marks follow from the weights by hand: minimum pixel spacing divided by
314    /// points per key unit gives the minimum numeric distance between labels.
315    #[test]
316    fn numeric_scales_encode_keys_and_label_the_roundest_in_reach() {
317        for key in [4512.5, 101.1, -0.5, 0.0, 360.0] {
318            assert_eq!(
319                key_time(key).map(time_key),
320                Some(key),
321                "round trip of {key}"
322            );
323        }
324        for key in [f64::NAN, f64::INFINITY, 1e10] {
325            assert_eq!(key_time(key), None, "{key}");
326        }
327
328        let price = PriceHorizontalScale { precision: 0 };
329        let strikes = [4400.0, 4450.0, 4500.0, 4525.0, 4550.0, 4575.0, 4600.0];
330        let months: Vec<f64> = (0..=36).map(f64::from).collect();
331        // Scale, keys, bar spacing, label spacing, uniform distribution, labels and bold labels.
332        type Case<'a> = (
333            &'a dyn HorizontalScale,
334            &'a [f64],
335            f32,
336            f32,
337            bool,
338            &'a [&'a str],
339            &'a [&'a str],
340        );
341        let cases: [Case; 4] = [
342            // 80 / 1.6 = 50 strike units between labels, including the irregular first gaps.
343            (
344                &price,
345                &strikes,
346                1.6,
347                80.0,
348                false,
349                &["4400", "4450", "4500", "4550", "4600"],
350                &["4400", "4500", "4600"],
351            ),
352            // 40 / 2 = 20 units: 110 is too near 100, while 140 fits unless its whole
353            // weight is rejected by uniform distribution.
354            (
355                &price,
356                &[100.0, 110.0, 140.0],
357                2.0,
358                40.0,
359                false,
360                &["100", "140"],
361                &["100"],
362            ),
363            (
364                &price,
365                &[100.0, 110.0, 140.0],
366                2.0,
367                40.0,
368                true,
369                &["100"],
370                &["100"],
371            ),
372            // Six months apart: 0 (10 years), 36 (3 years), whole years, then half years.
373            (
374                &MaturityScale::default(),
375                &months,
376                10.0,
377                60.0,
378                false,
379                &["0M", "6M", "1Y", "1Y6M", "2Y", "2Y6M", "3Y"],
380                &["0M"],
381            ),
382        ];
383        for (scale, keys, bar_spacing, min_spacing, uniform_distribution, labels, bold) in cases {
384            let points: Vec<_> = keys
385                .iter()
386                .enumerate()
387                .map(|(index, &key)| (key_time(key).unwrap(), index))
388                .collect();
389            let layout = TickMarkLayout {
390                width: 800.0,
391                bar_spacing,
392                min_spacing,
393                uniform_distribution,
394            };
395            let marks = scale.tick_marks(&points, &layout);
396            let shown: Vec<_> = marks.iter().map(|mark| mark.label.as_str()).collect();
397            assert_eq!(shown, labels);
398            let heavy: Vec<_> = marks
399                .iter()
400                .filter(|mark| mark.bold)
401                .map(|mark| mark.label.as_str())
402                .collect();
403            assert_eq!(heavy, bold);
404        }
405
406        let at = |key| key_time(key).unwrap();
407        assert_eq!(
408            PriceHorizontalScale::default().format_crosshair(at(4512.5), 0),
409            "4512.50"
410        );
411        let maturity = MaturityScale::default();
412        for (months, label) in [(3.0, "3M"), (30.0, "2Y6M"), (360.0, "30Y")] {
413            assert_eq!(maturity.format_crosshair(at(months), 0), label);
414        }
415
416        let mut quarterly = MaturityScale {
417            base_resolution: 3,
418            minimum_range: 12,
419            start: 0,
420        };
421        // Inclusive quarterly slots: 0 through 12,000 has 4,001 bars; 24,000 has 8,001.
422        for (minimum, last, slots) in [
423            (12, 7.0, 12.0),
424            (12, 20.0, 18.0),
425            (12, 12_003.0, 12_000.0),
426            (12, 1e12, 12_000.0),
427            (12, f64::INFINITY, 12.0),
428            (12, f64::NAN, 12.0),
429            (24_000, 1e12, 24_000.0),
430        ] {
431            quarterly.minimum_range = minimum;
432            let expected: Vec<_> = (0..=(slots as u32 / 3))
433                .map(|slot| at(f64::from(slot * 3)))
434                .collect();
435            let times: Vec<_> = quarterly
436                .whitespace(last)
437                .iter()
438                .map(|bar| bar.time)
439                .collect();
440            assert_eq!(times.len(), expected.len(), "whitespace to {last}");
441            assert_eq!(times, expected, "whitespace to {last}");
442        }
443    }
444}