Skip to main content

egui_charts/widget/
time_scale.rs

1//! Host control of the time scale: fitting, scrolling, logical ranges and coordinate conversions.
2
3use super::Chart;
4use crate::model::LogicalRange;
5use chrono::{DateTime, Utc};
6use egui::{Ui, Vec2};
7
8/// A scroll moving the right offset linearly to `to` over `duration` seconds.
9#[derive(Debug, Clone, Copy)]
10pub(super) struct ScrollAnimation {
11    to: f32,
12    duration: f64,
13    /// Start time and offset, taken on the first frame, and the offset the last frame set.
14    progress: Option<(f64, f64, f64)>,
15}
16
17impl Chart {
18    /// Fits every bar into the plot width, then restores the configured right offset.
19    ///
20    /// The right offset, in bars or pixels, is kept free of bars. The bar spacing limits still
21    /// apply.
22    pub fn fit_content(&mut self) {
23        self.cancel_view_motion();
24        self.state.time_scale_mut().fit_content();
25    }
26
27    /// Restores the configured bar spacing and right offset, leaving the price scale unchanged.
28    pub fn reset_time_scale(&mut self) {
29        self.cancel_view_motion();
30        let scale = self.state.time_scale_mut();
31        scale.set_bar_spacing(self.chart_options.time_scale.bar_spacing);
32        scale.scroll_to_realtime();
33    }
34
35    /// Restores the configured bar spacing and right margin, scrolls to the latest bar and
36    /// returns every price scale in every pane to auto-scaling. Scale modes and inversion stay.
37    /// Pending scrolls, animations and requested visible-bar counts are cancelled.
38    pub fn reset_chart_view(&mut self) {
39        self.apply_visible_bars_once = false;
40        self.desired_visible_bars = None;
41        self.tracking_mode_active = false;
42        self.reset_time_scale();
43        self.state.price_scale_mut().reset();
44        for scale in self.series_scales.values_mut() {
45            scale.reset();
46        }
47        for pane in &mut self.pane_layout.panes {
48            pane.scale.reset();
49        }
50    }
51
52    /// Scrolls to the latest bar with the configured right offset, keeping the zoom.
53    ///
54    /// Unlike [`Chart::enable_tracking_mode`], the view does not keep returning to the latest
55    /// bar afterwards. `animated` scrolls over 400 ms instead of jumping.
56    pub fn scroll_to_real_time(&mut self, animated: bool) {
57        self.cancel_view_motion();
58        let target = self.state.time_scale().live_edge_offset();
59        self.scroll_to(target, 0.4, animated);
60    }
61
62    /// Scrolls the latest bar `position` bars from the right edge, keeping the zoom.
63    ///
64    /// Positive positions leave empty space on the right and negative ones scroll into
65    /// history; the edge limits of the time scale options still apply. `animated` scrolls
66    /// over one second instead of jumping. Ignores non-finite positions.
67    pub fn scroll_to_position(&mut self, position: f32, animated: bool) {
68        if position.is_finite() {
69            self.cancel_view_motion();
70            self.tracking_mode_active = false;
71            self.scroll_to(position, 1.0, animated);
72        }
73    }
74
75    /// The visible range of logical indices, `None` without data.
76    ///
77    /// Index `0` is the oldest bar, or the oldest element on price-based chart types. Both
78    /// bounds are fractional and may lie outside the data, in whitespace before the first bar
79    /// or after the last. `right` is the index at the right edge less half a bar, so a range
80    /// spans `right - left + 1` bar widths of plot.
81    pub fn visible_logical_range(&self) -> Option<LogicalRange> {
82        (!self.state.shown().is_empty()).then(|| self.state.time_scale().visible_logical_range())
83    }
84
85    /// Shows the logical range across the plot width of the last frame, setting both the bar
86    /// spacing and the scroll position, so [`Chart::visible_logical_range`] returns it.
87    ///
88    /// Returns `false` without changing the view for empty data, non-finite bounds or a
89    /// `right` below `left`. The bar spacing and edge limits still apply.
90    pub fn set_visible_logical_range(&mut self, range: LogicalRange) -> bool {
91        let bars = range.right - range.left + 1.0;
92        if self.state.shown().is_empty() || !bars.is_finite() || range.right < range.left {
93            return false;
94        }
95        self.cancel_view_motion();
96        self.tracking_mode_active = false;
97        let scale = self.state.time_scale_mut();
98        scale.set_bar_spacing(scale.width() / bars);
99        scale.set_right_offset(range.right - scale.base_idx() as f32);
100        true
101    }
102
103    /// Calendar zone behind the axis; `None` selects the system's local zone.
104    pub(super) fn calendar_timezone(&self) -> Option<crate::model::TimezoneId> {
105        let timezone = self.display_timezone();
106        match timezone {
107            crate::scales::TimezoneMode::Local => None,
108            _ => Some(timezone.to_tz().unwrap_or_default()),
109        }
110    }
111
112    /// The UTC time at a logical index, `None` without data or for a non-finite index.
113    ///
114    /// Between bars the time is interpolated, and in whitespace it is extrapolated, as in
115    /// [`Chart::visible_time_range`], by the known timeframe duration or the most common positive
116    /// spacing among the outermost 16 bars (shorter spacing breaks ties, one second if none).
117    /// A step that divides an hour keeps the slots on the grid of the regular bars nearest the
118    /// edge, so a partial edge bar does not shift them. Known month and year timeframes step by
119    /// calendar months in the display time zone when the edge bars fall at its midnight, and in
120    /// UTC otherwise.
121    pub fn index_to_time(&self, index: f64) -> Option<DateTime<Utc>> {
122        crate::model::time_at_with_timeframe(
123            self.state.shown(),
124            index,
125            self.timeframe(),
126            self.calendar_timezone().as_ref(),
127        )
128    }
129
130    /// The fractional logical index of a UTC time, `None` without data.
131    ///
132    /// The inverse of [`Chart::index_to_time`]: a time between two bars lies between their
133    /// indices, and a time outside the data is extrapolated into whitespace.
134    pub fn time_to_index(&self, time: DateTime<Utc>) -> Option<f64> {
135        crate::model::index_at_with_timeframe(
136            self.state.shown(),
137            time,
138            self.timeframe(),
139            self.calendar_timezone().as_ref(),
140        )
141    }
142
143    /// Gives the horizontal axis a meaning other than time, such as option strikes or bond
144    /// maturities. The [`time`](crate::model::Bar::time) of each bar, marker and drawing then
145    /// holds a key of `scale`, which labels the axis and the crosshair; everything placed by
146    /// time is placed by key. `None` restores the time scale.
147    ///
148    /// Session breaks and extended-hours shading are periods of time, drawn on the time scale
149    /// only.
150    pub fn set_horizontal_scale(
151        &mut self,
152        scale: Option<std::sync::Arc<dyn crate::scales::HorizontalScale>>,
153    ) {
154        self.horizontal_scale = scale;
155    }
156
157    /// The screen x of a logical index in the last frame, at the centre of its bar.
158    ///
159    /// `None` before the chart is shown or for a non-finite index.
160    pub fn index_to_x(&self, index: f64) -> Option<f32> {
161        let plot = self.last_rendered_price_rect;
162        (index.is_finite() && plot.width() > 0.0).then(|| {
163            self.state
164                .time_scale()
165                .idx_to_coord_precise(index, plot.left(), plot.width())
166        })
167    }
168
169    /// The fractional logical index at a screen x in the last frame, the inverse of
170    /// [`Chart::index_to_x`]. Round it for the nearest bar.
171    ///
172    /// `None` before the chart is shown or for a non-finite x.
173    pub fn x_to_index(&self, x: f32) -> Option<f64> {
174        let plot = self.last_rendered_price_rect;
175        (x.is_finite() && plot.width() > 0.0).then(|| {
176            self.state
177                .time_scale()
178                .coord_to_idx_precise(x, plot.left(), plot.width())
179        })
180    }
181
182    /// The size of the time axis below the plots in the last frame, in points.
183    ///
184    /// The width is the plot width; the height is zero while time labels are hidden.
185    pub fn time_scale_size(&self) -> Vec2 {
186        Vec2::new(
187            self.last_rendered_price_rect.width().max(0.0),
188            self.time_axis_rect.height().max(0.0),
189        )
190    }
191
192    /// The time axis height: the label row, or the configured minimum when larger, and zero
193    /// while time labels are hidden.
194    pub(super) fn time_axis_height(&self) -> f32 {
195        if self.config.show_time_labels {
196            crate::styles::sizing::chart::BOTTOM_PADDING_WITH_TIME
197                .max(self.chart_options.time_scale.minimum_height)
198        } else {
199            0.0
200        }
201    }
202
203    /// The x, relative to the plot, that a zoom keeps in place: `x`, or the last pixel, which
204    /// keeps the right offset, while the right bar stays on scroll.
205    pub(super) fn zoom_anchor(&self, x: f32, width: f32) -> f32 {
206        if self.chart_options.time_scale.right_bar_stays_on_scroll {
207            width - 1.0
208        } else {
209            x
210        }
211    }
212
213    /// Moves the right offset this frame of a scroll animation; any other change of the
214    /// offset since the previous frame, by the user, the host or new data, ends it.
215    pub(super) fn advance_scroll_animation(&mut self, ui: &Ui) {
216        let Some(mut animation) = self.scroll_animation.take() else {
217            return;
218        };
219        let now = ui.input(|input| input.time);
220        let scale = self.state.time_scale_mut();
221        let (start, from, last) = *animation.progress.get_or_insert((
222            now,
223            scale.right_offset_precise(),
224            scale.right_offset_precise(),
225        ));
226        if scale.right_offset_precise() != last {
227            return;
228        }
229        let elapsed = ((now - start) / animation.duration) as f32;
230        if elapsed >= 1.0 {
231            scale.set_right_offset(animation.to);
232            return;
233        }
234        scale.set_right_offset(from + (f64::from(animation.to) - from) * f64::from(elapsed));
235        animation.progress = Some((start, from, scale.right_offset_precise()));
236        self.scroll_animation = Some(animation);
237        ui.ctx().request_repaint();
238    }
239
240    fn scroll_to(&mut self, offset: f32, duration: f64, animated: bool) {
241        if animated {
242            self.scroll_animation = Some(ScrollAnimation {
243                to: offset,
244                duration,
245                progress: None,
246            });
247        } else {
248            self.state.time_scale_mut().set_right_offset(offset);
249        }
250    }
251
252    /// Ends pending scrolls, animations and momentum, so a programmatic view replaces them.
253    pub(super) fn cancel_view_motion(&mut self) {
254        self.pending_scroll_time = None;
255        self.pending_start_idx = None;
256        self.scroll_animation = None;
257        self.kinetic_scroll = super::state::KineticScrollState::new();
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use crate::model::{Bar, BarData, LogicalRange};
264    use crate::scales::{PriceHorizontalScale, key_time};
265    use crate::widget::test_frames::{chart, frame, shapes};
266    use crate::widget::{Chart, ChartEvent, SeriesId};
267    use chrono::Duration;
268
269    fn assert_close(actual: f64, expected: f64, what: &str) {
270        assert!(
271            (actual - expected).abs() < 1e-3,
272            "{what}: {actual} != {expected}"
273        );
274    }
275
276    fn assert_range(chart: &Chart, left: f32, right: f32, what: &str) {
277        let range = chart.visible_logical_range().unwrap();
278        assert_close(range.left.into(), left.into(), what);
279        assert_close(range.right.into(), right.into(), what);
280    }
281
282    #[test]
283    fn zero_margin_keeps_the_latest_bar_and_its_live_updates_visible() {
284        let ctx = egui::Context::default();
285        let mut chart = chart(&[95.0], &[105.0]);
286        let first = chart.bars(SeriesId::MAIN)[0].time;
287        chart.set_data(
288            SeriesId::MAIN,
289            (0..1000)
290                .map(|index| {
291                    Bar::new(
292                        first + Duration::minutes(index),
293                        95.0,
294                        105.0,
295                        95.0,
296                        105.0,
297                        100.0,
298                    )
299                })
300                .collect(),
301        );
302        let mut options = chart.chart_options().clone();
303        options.time_scale.right_offset = 0.0;
304        options.time_scale.fix_right_edge = true;
305        chart.set_chart_options(options);
306        for time in [0.0, 0.1, 0.2] {
307            frame(&ctx, &mut chart, time, vec![]);
308        }
309        let last = chart.bars(SeriesId::MAIN).len() - 1;
310        let plot = chart.get_rendered_price_rect();
311        assert!((plot.left()..plot.right()).contains(&chart.index_to_x(last as f64).unwrap()));
312        assert_eq!(chart.state().visible_range().1, last + 1);
313        let mut update = chart.bars(SeriesId::MAIN)[last].clone();
314        update.close = 110.0;
315        update.high = 110.0;
316        chart.update(SeriesId::MAIN, update);
317        frame(&ctx, &mut chart, 0.3, vec![]);
318        assert_eq!(chart.state().visible_data().last().unwrap().close, 110.0);
319        assert!(
320            chart
321                .series_price_map(SeriesId::MAIN)
322                .unwrap()
323                .range()
324                .contains(110.0)
325        );
326    }
327
328    #[test]
329    fn host_coordinates_retain_fractions_on_large_histories() {
330        let bars = (0..1_000_000)
331            .map(|i| {
332                Bar::from_value(
333                    chrono::DateTime::from_timestamp(1_700_000_000 + i * 60, 0).unwrap(),
334                    10.0,
335                )
336            })
337            .collect();
338        let mut chart = Chart::new(BarData::from_bars(bars));
339        let ctx = egui::Context::default();
340        frame(&ctx, &mut chart, 0.0, vec![]);
341        chart.state.time_scale_mut().set_bar_spacing(200.0);
342        chart.state.time_scale_mut().set_right_offset(0.0);
343        let x = chart.index_to_x(999_999.0).unwrap();
344        let fractional = chart.index_to_x(999_999.005).unwrap();
345        assert!((fractional - x - 1.0).abs() < 1e-4);
346        assert!((chart.x_to_index(fractional).unwrap() - 999_999.005).abs() < 1e-6);
347        let scale = chart.state.time_scale();
348        let distance = (f64::from(scale.width()) - 1.0) / 200.0 - 0.5;
349        let expected = chart.bars(SeriesId::MAIN).last().unwrap().time
350            - Duration::nanoseconds((distance * 60e9).round() as i64);
351        assert!(
352            (chart.visible_time_range().unwrap().from - expected)
353                .num_nanoseconds()
354                .unwrap()
355                .abs()
356                < 1_000
357        );
358        let plot = chart.get_rendered_price_rect();
359        for (step, (index, nearest)) in [(999_997.49, 999_997), (999_997.51, 999_998)]
360            .into_iter()
361            .enumerate()
362        {
363            let pos = egui::pos2(chart.index_to_x(index).unwrap(), plot.center().y);
364            frame(
365                &ctx,
366                &mut chart,
367                0.1 + step as f64 * 0.1,
368                vec![egui::Event::PointerMoved(pos)],
369            );
370            assert!(
371                chart.drain_events().any(|event| matches!(
372                    event,
373                    ChartEvent::CrosshairMoved(Some(pointer)) if pointer.index == nearest
374                )),
375                "index {index} must select bar {nearest}"
376            );
377        }
378        let time = chart.bars(SeriesId::MAIN)[999_997].time + Duration::milliseconds(300);
379        let added = chart.add_series(crate::widget::SeriesOptions {
380            chart_type: crate::model::ChartType::Candles,
381            up_color: egui::Color32::BLUE,
382            ..Default::default()
383        });
384        chart.set_data(added, vec![Bar::new(time, 9.0, 11.0, 9.0, 11.0, 100.0)]);
385        let painted = shapes(&ctx, &mut chart, 0.3, vec![]);
386        let x = chart.index_to_x(999_997.005).unwrap();
387        assert!(
388            painted.iter().any(|shape| matches!(shape,
389                egui::Shape::Rect(body)
390                    if body.fill == egui::Color32::BLUE && (body.rect.center().x - x).abs() < 1e-3
391            )),
392            "an added series must retain its fractional time position"
393        );
394        let plot = chart.get_rendered_price_rect();
395        let center = 999_997.137;
396        let x = chart.index_to_x(center).unwrap();
397        assert!(chart.execute_box_zoom(
398            egui::pos2(x - 125.0, plot.top() + 100.0),
399            egui::pos2(x + 125.0, plot.top() + 200.0),
400            plot,
401            plot.width(),
402        ));
403        assert!((chart.index_to_x(center).unwrap() - plot.center().x).abs() < 1e-3);
404        chart.state.time_scale_mut().set_bar_spacing(200.0);
405        chart.state.time_scale_mut().set_right_offset(-500_000.005);
406        let x = chart.index_to_x(499_999.0).unwrap();
407        assert_eq!(chart.get_chart_mapping().idx_to_x(499_999), x);
408        chart.state.time_scale_mut().scroll_pixels(1.0);
409        assert!((chart.index_to_x(499_999.0).unwrap() - x - 1.0).abs() < 1e-4);
410    }
411
412    /// Expected values follow from the time scale's definition on 100 daily bars: a logical
413    /// range spans `right - left + 1` bar widths, bar `i` is centred `right + 0.5 - i` bar
414    /// widths left of the plot's last pixel, and the defaults are a spacing of 6 and a right
415    /// offset of 2.5 bars.
416    #[test]
417    fn time_scale_methods_set_read_and_convert_the_view() {
418        let ctx = egui::Context::default();
419        let mut chart = chart(&[95.0], &[105.0]);
420        for t in [0.0, 0.1] {
421            frame(&ctx, &mut chart, t, vec![]);
422        }
423        let plot = chart.get_rendered_price_rect();
424        let width = plot.width();
425        let events: Vec<_> = chart.drain_events().collect();
426        assert_eq!(chart.time_scale_size(), egui::vec2(width, 35.0));
427        assert!(events.contains(&ChartEvent::TimeScaleResized(chart.time_scale_size())));
428        assert!(events.contains(&ChartEvent::VisibleLogicalRangeChanged(
429            chart.visible_logical_range()
430        )));
431
432        // 40 bar widths across the plot, with bar 49 the last whole bar.
433        assert!(chart.set_visible_logical_range(LogicalRange::new(10.0, 49.0)));
434        let spacing = chart.get_time_scale_state().0;
435        assert_close(spacing.into(), (width / 40.0).into(), "spacing");
436        assert_close(chart.get_time_scale_state().1.into(), -50.0, "offset");
437        assert_range(&chart, 10.0, 49.0, "set range");
438        // Bar 10 is centred half a bar in from the left edge: the last pixel is width - 1.
439        let x = chart.index_to_x(10.0).unwrap();
440        assert_close(x.into(), (plot.left() + spacing / 2.0 - 1.0).into(), "x");
441        assert_close(chart.x_to_index(x).unwrap(), 10.0, "index");
442        frame(&ctx, &mut chart, 0.2, vec![]);
443        let range = chart.visible_logical_range();
444        assert!(
445            chart
446                .drain_events()
447                .any(|event| event == ChartEvent::VisibleLogicalRangeChanged(range))
448        );
449        for invalid in [
450            LogicalRange::new(5.0, 4.0),
451            LogicalRange::new(f32::NAN, 4.0),
452        ] {
453            assert!(!chart.set_visible_logical_range(invalid));
454        }
455        assert_range(&chart, 10.0, 49.0, "invalid ranges");
456
457        // Daily bars: 10.5 is noon of day 10, and 105 extrapolates past the last bar.
458        let first = chart.bars(SeriesId::MAIN)[0].time;
459        let noon = first + Duration::hours(10 * 24 + 12);
460        assert_eq!(chart.index_to_time(10.5), Some(noon));
461        assert_close(chart.time_to_index(noon).unwrap(), 10.5, "time");
462        assert_eq!(
463            chart.index_to_time(105.0),
464            Some(first + Duration::days(105))
465        );
466
467        // 100 bars and the 2.5-bar right offset share the width.
468        chart.fit_content();
469        assert_close(
470            chart.get_time_scale_state().0.into(),
471            (width / 102.5).into(),
472            "fit",
473        );
474        assert_range(&chart, 0.0, 101.5, "fit");
475        chart.reset_time_scale();
476        assert_eq!(chart.get_time_scale_state(), (6.0, 2.5));
477
478        chart.scroll_to_position(-20.0, false);
479        assert_eq!(chart.get_time_scale_state().1, -20.0);
480        // Animations start on the next frame and move linearly: one second to a position,
481        // 400 ms back to the live edge.
482        chart.scroll_to_position(10.0, true);
483        for (t, offset) in [(1.0, -20.0), (1.5, -5.0), (2.0, 10.0), (2.1, 10.0)] {
484            frame(&ctx, &mut chart, t, vec![]);
485            assert_close(chart.get_time_scale_state().1.into(), offset, "position");
486        }
487        chart.scroll_to_real_time(true);
488        for (t, offset) in [(3.0, 10.0), (3.2, 6.25), (3.4, 2.5)] {
489            frame(&ctx, &mut chart, t, vec![]);
490            assert_close(chart.get_time_scale_state().1.into(), offset, "real time");
491        }
492        // Any other change of the view ends an animation.
493        chart.scroll_to_position(-20.0, true);
494        frame(&ctx, &mut chart, 4.0, vec![]);
495        chart.apply_synced_time_scale(6.0, 0.0);
496        frame(&ctx, &mut chart, 4.5, vec![]);
497        assert_eq!(chart.get_time_scale_state().1, 0.0);
498
499        let mut options = chart.chart_options().clone();
500        options.time_scale.minimum_height = 40.0;
501        options.time_scale.right_offset_pixels = Some(60.0);
502        let mut chart = chart.with_chart_options(options);
503        frame(&ctx, &mut chart, 5.0, vec![]);
504        assert_eq!(chart.time_scale_size(), egui::vec2(width, 40.0));
505        assert!(
506            chart
507                .drain_events()
508                .any(|event| event == ChartEvent::TimeScaleResized(egui::vec2(width, 40.0)))
509        );
510        // A pixel offset is kept free of bars, so all 100 fit the remaining width.
511        chart.fit_content();
512        assert_close(
513            chart.get_time_scale_state().0.into(),
514            ((width - 60.0) / 100.0).into(),
515            "fit with pixels",
516        );
517        assert_range(
518            &chart,
519            0.0,
520            99.0 + 60.0 / chart.get_time_scale_state().0,
521            "fit with pixels",
522        );
523    }
524
525    /// Strikes 4400 to 4600 in steps of 25 across the plot are about 80 points apart, so the
526    /// axis labels every other one, the multiples of 50, by the scale's weights; the crosshair
527    /// on 4475 reads it, the CSV export writes strikes, and no time is formatted anywhere.
528    #[test]
529    fn a_horizontal_scale_labels_the_axis_and_the_crosshair_by_key() {
530        let bars = (0..=8)
531            .map(|i| Bar::from_value(key_time(4400.0 + 25.0 * f64::from(i)).unwrap(), 20.0))
532            .collect();
533        let mut chart = Chart::new(BarData::from_bars(bars));
534        chart.set_horizontal_scale(Some(std::sync::Arc::new(PriceHorizontalScale {
535            precision: 0,
536        })));
537        let ctx = egui::Context::default();
538        frame(&ctx, &mut chart, 0.0, vec![]);
539        assert!(chart.set_visible_logical_range(LogicalRange::new(0.0, 8.0)));
540        frame(&ctx, &mut chart, 0.1, vec![]);
541        let plot = chart.get_rendered_price_rect();
542        let hover = egui::pos2(chart.index_to_x(3.0).unwrap(), plot.center().y);
543        let texts = |painted: Vec<egui::Shape>| -> Vec<String> {
544            painted
545                .into_iter()
546                .filter_map(|shape| match shape {
547                    egui::Shape::Text(text) if text.pos.y >= plot.bottom() => {
548                        Some(text.galley.text().to_owned())
549                    }
550                    _ => None,
551                })
552                .collect()
553        };
554        let axis = texts(shapes(&ctx, &mut chart, 0.2, vec![]));
555        assert_eq!(axis, ["4400", "4450", "4500", "4550", "4600"]);
556        let hovered = texts(shapes(
557            &ctx,
558            &mut chart,
559            0.3,
560            vec![egui::Event::PointerMoved(hover)],
561        ));
562        assert!(hovered.contains(&"4475".to_owned()), "{hovered:?}");
563        #[cfg(feature = "csv")]
564        {
565            let csv = chart.export_csv().unwrap();
566            assert_eq!(
567                csv.lines()
568                    .nth(1)
569                    .and_then(|row| row.split_once(','))
570                    .map(|(key, _)| key),
571                Some("4400")
572            );
573        }
574    }
575}