Skip to main content

egui_charts/studies/
chart_state.rs

1//! Chart state management (model layer, no rendering).
2//!
3//! [`ChartState`] is the back-end owner of bar data and coordinate systems.
4//! It tracks which bars are visible, manages price auto-scaling, and maintains
5//! a zoom-history stack for undo/redo.
6
7use crate::model::{Bar, BarData, TimeScale, next_revision};
8use crate::scales::{PriceScale, PriceScaleMode, PriceScaleOptions, PriceTransform};
9use crate::studies::Indicator;
10use std::sync::Arc;
11/// A snapshot of zoom parameters, used for undo/redo of zoom operations.
12///
13/// The chart maintains a stack of these snapshots so the user can press
14/// "zoom out" to return to a previous view.
15#[derive(Debug, Clone, Copy)]
16pub struct ZoomState {
17    /// Pixels per bar at the time of the snapshot.
18    pub bar_spacing: f32,
19    /// Right offset in bars from the chart edge.
20    pub right_offset: f64,
21    /// Manual price range in prices, or `None` when auto-scaled.
22    pub price_range: Option<(f64, f64)>,
23}
24
25/// Backend chart state -- owns bar data, time-scale coordinate system,
26/// price auto-scaling logic, and zoom history.
27///
28/// This is the pure-logic layer with no UI or rendering dependencies.  The UI
29/// layer reads from `ChartState` to determine what to draw and writes back
30/// interaction results (scroll, zoom, data updates).
31///
32/// # Example
33///
34/// ```
35/// use egui_charts::model::BarData;
36/// use egui_charts::studies::ChartState;
37///
38/// let data = BarData::new();
39/// let state = ChartState::new(data);
40/// assert!(state.visible_data().is_empty());
41/// ```
42#[derive(Debug)]
43pub struct ChartState {
44    /// The OHLCV bar dataset.
45    data: BarData,
46    /// The price-based elements currently shown, one bar per element.
47    elements: Option<Arc<[Bar]>>,
48    /// Time coordinate system (logical index <-> pixel mapping).
49    time_scale: TimeScale,
50    /// The main series' price scale.
51    price_scale: PriceScale,
52    /// Stack of previous zoom snapshots for the "zoom out" button.
53    zoom_history: Vec<ZoomState>,
54    /// See [`revision`](Self::revision).
55    revision: u64,
56}
57
58impl Clone for ChartState {
59    /// A copy with a revision of its own, since its bars change independently.
60    fn clone(&self) -> Self {
61        Self {
62            data: self.data.clone(),
63            elements: self.elements.clone(),
64            time_scale: self.time_scale.clone(),
65            price_scale: self.price_scale.clone(),
66            zoom_history: self.zoom_history.clone(),
67            revision: next_revision(),
68        }
69    }
70}
71
72impl ChartState {
73    /// Create new chart state with data
74    pub fn new(data: BarData) -> Self {
75        let mut time_scale = TimeScale::new();
76        time_scale.set_bar_cnt(data.len());
77
78        Self {
79            data,
80            elements: None,
81            time_scale,
82            price_scale: PriceScale::new(PriceScaleOptions::default()),
83            zoom_history: Vec::new(),
84            revision: next_revision(),
85        }
86    }
87
88    /// Get reference to bar data
89    pub fn data(&self) -> &BarData {
90        &self.data
91    }
92
93    /// Get mutable reference to time scale
94    pub fn time_scale_mut(&mut self) -> &mut TimeScale {
95        &mut self.time_scale
96    }
97
98    /// Get reference to time scale
99    pub fn time_scale(&self) -> &TimeScale {
100        &self.time_scale
101    }
102
103    /// Identifies the bars before the last one. It changes when any of them
104    /// changes or bars are removed, and stays the same when bars are appended
105    /// or the last bar is updated, so results computed from the bars can be
106    /// cached under it and extended as the series streams.
107    pub fn revision(&self) -> u64 {
108        self.revision
109    }
110
111    /// Replaces the bars (also updates the bar count of the time scale). Returns the index of the
112    /// first bar that differs from the previous bars, counting a change of length: the shorter
113    /// length when one is a prefix of the other, `None` when they are identical.
114    pub fn set_data(&mut self, data: BarData) -> Option<usize> {
115        self.edit_bars(|bars| {
116            // Bitwise, so that a NaN compares equal to itself.
117            let bits = |bar: &Bar| {
118                (
119                    bar.time,
120                    [bar.open, bar.high, bar.low, bar.close, bar.volume].map(f64::to_bits),
121                )
122            };
123            let first = bars
124                .iter()
125                .zip(&data.bars)
126                .position(|(old, new)| bits(old) != bits(new))
127                .or_else(|| (bars.len() != data.len()).then(|| bars.len().min(data.len())));
128            *bars = data.bars;
129            first
130        })
131    }
132
133    /// Runs `edit` on the bars and keeps the time scale and [`revision`](Self::revision) in step.
134    /// `edit` returns the index of the first bar it inserted, changed or removed; bars after it
135    /// may have moved.
136    pub(crate) fn edit_bars(
137        &mut self,
138        edit: impl FnOnce(&mut Vec<Bar>) -> Option<usize>,
139    ) -> Option<usize> {
140        let old_len = self.data.len();
141        let first_changed = edit(&mut self.data.bars);
142        if first_changed.is_some_and(|first| first < old_len.saturating_sub(1))
143            || self.data.len() < old_len
144        {
145            self.revision = next_revision();
146        }
147        let shown = self.elements.as_deref().unwrap_or(&self.data.bars);
148        #[cfg(feature = "widget")]
149        self.time_scale
150            .refresh_numeric_positions(shown, self.revision);
151        self.time_scale.set_bar_cnt(shown.len());
152        first_changed
153    }
154
155    /// The series drawn on the time scale, one OHLC bar per element on price-based charts.
156    /// Indices returned by the chart hover and selection APIs refer to this series.
157    pub fn shown(&self) -> &[Bar] {
158        self.elements.as_deref().unwrap_or(&self.data.bars)
159    }
160
161    #[cfg(feature = "widget")]
162    pub(crate) fn shows_elements(&self) -> bool {
163        self.elements.is_some()
164    }
165
166    /// Keeps the right-edge item by time and rank when the shown series changes.
167    #[cfg(feature = "widget")]
168    pub(crate) fn set_elements(&mut self, elements: Option<Arc<[Bar]>>, follow: bool) {
169        match (&self.elements, &elements) {
170            (Some(old), Some(new)) if Arc::ptr_eq(old, new) => return,
171            (None, None) => return,
172            _ => {}
173        }
174        let old = self.shown();
175        let new = elements.as_deref().unwrap_or(&self.data.bars);
176        let ts = &self.time_scale;
177        let near_live = ts.is_near_live_edge();
178        let kept = (!old.is_empty() && !new.is_empty()).then(|| {
179            // Several elements can share the completion time of one source bar.
180            let right = ts.base_idx() as f64 + ts.right_offset_precise();
181            let at = (right.floor().max(0.0) as usize).min(old.len() - 1);
182            let time = old[at].time;
183            let rank = at - old.partition_point(|bar| bar.time < time);
184            let first = new.partition_point(|bar| bar.time < time);
185            let same = new[first..].partition_point(|bar| bar.time == time);
186            let to = (first + rank.min(same.saturating_sub(1))).min(new.len() - 1);
187            let newer = new[new.len() - 1].time > old[old.len() - 1].time;
188            (
189                to as f64 + (right - at as f64) - (new.len() - 1) as f64,
190                newer,
191            )
192        });
193        self.elements = elements;
194        let shown = self.elements.as_deref().unwrap_or(&self.data.bars);
195        self.time_scale
196            .refresh_numeric_positions(shown, self.revision);
197        self.time_scale.set_bar_cnt(shown.len());
198        match kept {
199            Some((_, true)) if follow && near_live => self.time_scale.scroll_to_realtime(),
200            Some(_) if follow && near_live => {}
201            Some((offset, _)) => self.time_scale.set_right_offset(offset),
202            None => {}
203        }
204    }
205
206    /// The bars of [`visible_range`](Self::visible_range): source bars, or one OHLC bar per
207    /// element on a price-based chart type.
208    pub fn visible_data(&self) -> &[crate::model::Bar] {
209        let (start, end) = self.visible_range();
210        &self.shown()[start..end]
211    }
212
213    /// Indices of the bars in view, end exclusive, counting elements on price-based chart
214    /// types and bars otherwise. A bar the plot's edge cuts is in view. With no bar in view
215    /// the range is empty: it starts at 0 before the first bar and at the number of bars
216    /// after the last.
217    pub fn visible_range(&self) -> (usize, usize) {
218        let (start, end) = self.time_scale.visible_logical_range().to_strict_range();
219        let end = end.min(self.shown().len());
220        (start.min(end), end)
221    }
222
223    /// Returns the main series' price scale.
224    pub fn price_scale(&self) -> &PriceScale {
225        &self.price_scale
226    }
227
228    /// Returns the main series' price scale for changing its options or range.
229    pub fn price_scale_mut(&mut self) -> &mut PriceScale {
230        &mut self.price_scale
231    }
232
233    /// Fits the price scale to the visible bars and the visible values of the visible overlay
234    /// studies in `indicators`, unless the user scaled by hand, and returns this frame's
235    /// transform: percentage and indexed modes are relative to the first visible close.
236    pub fn fit_price_scale<'a>(
237        &mut self,
238        indicators: impl IntoIterator<Item = &'a dyn Indicator>,
239    ) -> PriceTransform {
240        self.fit_price_scale_with_series(
241            indicators,
242            None,
243            true,
244            None,
245            None,
246            std::iter::empty(),
247            std::iter::empty(),
248        )
249        .0
250    }
251
252    /// Fits visible series and studies together. `autoscale` replaces the main series' range of
253    /// lows and highs; `prices` are further prices to fit, such as those of markers;
254    /// `series_values` are already in scale units. `main_bars` supplies transformed visible
255    /// bars; `main_range` reuses a precomputed exact range before calling the autoscale provider.
256    /// Returns the transform and the fitted data bounds in scale units, before margins.
257    pub(crate) fn fit_price_scale_with_series<'a>(
258        &mut self,
259        indicators: impl IntoIterator<Item = &'a dyn Indicator>,
260        main_bars: Option<&[Bar]>,
261        main_visible: bool,
262        autoscale: Option<&crate::model::AutoscaleProvider>,
263        main_range: Option<(f64, f64)>,
264        prices: impl IntoIterator<Item = f64>,
265        series_values: impl IntoIterator<Item = f64>,
266    ) -> (PriceTransform, (f64, f64)) {
267        let mode = self.price_scale.options().mode;
268        let (start, end) = self.visible_range();
269        let visible = main_bars.unwrap_or(&self.shown()[start..end]);
270        // In whitespace retain the nearest reference bar so relative axis units stay valid: the
271        // first bar with a value from the left edge on, or else the last one before it.
272        let at = start.min(self.shown().len().saturating_sub(1));
273        let base = self.shown()[at..]
274            .iter()
275            .find(|bar| !bar.is_whitespace())
276            .or_else(|| crate::model::last_with_value(&self.shown()[..at]))
277            .map_or(f64::NAN, |bar| bar.close);
278        let transform = PriceTransform::new(mode, base);
279        // Unclamped, so that displaced values and projections past the last bar are fitted.
280        let (study_start, study_end) = self.time_scale.visible_logical_range().to_strict_range();
281        let study_values = indicators
282            .into_iter()
283            .filter(|study| {
284                study.is_visible()
285                    && study.is_overlay()
286                    && study.scale() == crate::studies::StudyScale::Main
287            })
288            .flat_map(|study| {
289                #[cfg(not(feature = "drawings"))]
290                let drawing_prices = std::iter::empty();
291                #[cfg(feature = "drawings")]
292                let drawing_prices =
293                    crate::studies::plots::drawing_prices(study, study_start..study_end);
294                (0..study.line_count())
295                    .filter(|&index| {
296                        let plot = study.plot_style(index);
297                        plot.visible
298                            && !matches!(
299                                plot.plot_type,
300                                crate::studies::PlotType::BarColor
301                                    | crate::studies::PlotType::BackgroundColor
302                            )
303                    })
304                    .flat_map(move |index| {
305                        let available_end = study
306                            .values()
307                            .len()
308                            .checked_add_signed(study.plot_offset(index))
309                            .unwrap_or(0);
310                        (study_start..study_end.min(available_end))
311                            .filter_map(move |bar| study.plot_value(index, bar))
312                            .chain(std::iter::once(0.0).filter(move |_| {
313                                matches!(
314                                    study.plot_style(index).plot_type,
315                                    crate::studies::PlotType::Histogram
316                                        | crate::studies::PlotType::Columns
317                                        | crate::studies::PlotType::Area
318                                )
319                            }))
320                    })
321                    .chain(
322                        study
323                            .candles()
324                            .iter()
325                            .filter(|candle| {
326                                candle.bars.end > study_start
327                                    && candle.bars.start < study_end
328                                    && study.plot_style(candle.plot).visible
329                            })
330                            .flat_map(|candle| [candle.bar.low, candle.bar.high]),
331                    )
332                    .chain(
333                        study
334                            .zones()
335                            .iter()
336                            .filter(|zone| {
337                                zone.bars.end > study_start
338                                    && zone.bars.start < study_end
339                                    && study.plot_style(zone.plot).visible
340                            })
341                            .flat_map(|zone| [zone.bottom, zone.top]),
342                    )
343                    .chain(
344                        study
345                            .projections()
346                            .iter()
347                            .filter(|projection| {
348                                projection.bars.end > study_start as f64
349                                    && projection.bars.start < study_end as f64
350                                    && study.plot_style(projection.plot).visible
351                            })
352                            .flat_map(|projection| [projection.bottom, projection.top]),
353                    )
354                    .chain(
355                        study
356                            .volume_profiles(study_start..study_end)
357                            .into_iter()
358                            .flat_map(|plot| [plot.profile.low(), plot.profile.high()]),
359                    )
360                    .chain(drawing_prices)
361            });
362        let main = main_visible
363            .then(|| {
364                let range = main_range.or_else(|| {
365                    visible
366                        .iter()
367                        .flat_map(|bar| [bar.low, bar.high])
368                        .chain(
369                            visible
370                                .iter()
371                                .flat_map(|bar| [bar.open, bar.close])
372                                .filter(|_| mode == PriceScaleMode::Logarithmic),
373                        )
374                        .filter(|value| transform.to_scale(*value).is_finite())
375                        .fold(None, |range: Option<(f64, f64)>, value| {
376                            Some(range.map_or((value, value), |(min, max)| {
377                                (min.min(value), max.max(value))
378                            }))
379                        })
380                });
381                match autoscale {
382                    Some(provider) => provider.range(visible, range),
383                    None => range,
384                }
385            })
386            .flatten();
387        let (min, max) = main
388            .into_iter()
389            .flat_map(|(min, max)| [min, max])
390            .chain(study_values)
391            .chain(prices)
392            .map(|value| transform.to_scale(value))
393            .chain(series_values)
394            .filter(|value| value.is_finite())
395            .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), value| {
396                (min.min(value), max.max(value))
397            });
398        self.price_scale.rebase(transform);
399        self.price_scale.autoscale(min, max);
400        (transform, (min, max))
401    }
402
403    /// Save current zoom state to history
404    pub fn push_zoom_state(&mut self) {
405        let (range, t) = (self.price_scale.range(), self.price_scale.transform());
406        let curr_state = ZoomState {
407            bar_spacing: self.time_scale.bar_spacing(),
408            right_offset: self.time_scale.right_offset_precise(),
409            price_range: self
410                .price_scale
411                .is_manual()
412                .then(|| (t.to_price(range.min), t.to_price(range.max))),
413        };
414        self.zoom_history.push(curr_state);
415    }
416
417    /// Restore previous zoom state (zoom out button)
418    pub fn pop_zoom_state(&mut self) -> bool {
419        if let Some(state) = self.zoom_history.pop() {
420            self.time_scale.set_bar_spacing(state.bar_spacing);
421            self.time_scale.set_right_offset(state.right_offset);
422            // In the units of the scale's transform; the next fit rebases them. A price that
423            // has no finite value there (0 on a logarithmic scale) resets the range instead.
424            let t = self.price_scale.transform();
425            let range = state
426                .price_range
427                .map(|(min, max)| (t.to_scale(min), t.to_scale(max)));
428            match range {
429                Some((min, max)) if min.is_finite() && max.is_finite() => {
430                    self.price_scale.set_range(min, max)
431                }
432                _ => self.price_scale.reset(),
433            }
434            true
435        } else {
436            false
437        }
438    }
439
440    /// Clear zoom history
441    pub fn clear_zoom_history(&mut self) {
442        self.zoom_history.clear();
443    }
444
445    /// Check if there are zoom states to restore
446    pub fn has_zoom_history(&self) -> bool {
447        !self.zoom_history.is_empty()
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::model::Bar;
455    use chrono::{TimeZone, Utc};
456
457    /// Time and rank identify the right-edge item across type changes and trimmed builds.
458    #[test]
459    #[cfg(feature = "widget")]
460    fn a_new_series_keeps_the_item_at_the_right_edge() {
461        let bars = |minutes: &[i64]| -> Vec<Bar> {
462            minutes
463                .iter()
464                .map(|&minute| {
465                    Bar::new(
466                        Utc.timestamp_opt(1_700_000_000 + minute * 60, 0).unwrap(),
467                        100.0,
468                        100.0,
469                        100.0,
470                        100.0,
471                        1.0,
472                    )
473                })
474                .collect()
475        };
476        let e: &[i64] = &[1, 3, 3, 3, 6, 9];
477        // Edge = last index + offset; duplicate times retain their rank.
478        for (name, old, offset, new, follow, expected) in [
479            ("bars to elements", None, -4.0, Some(e), true, -1.0),
480            ("elements to bars", Some(e), -2.0, None, true, -6.0),
481            (
482                "oldest element dropped",
483                Some(e),
484                -2.0,
485                Some(&[3, 3, 3, 6, 9, 12][..]),
486                true,
487                -3.0,
488            ),
489            (
490                "following off",
491                Some(e),
492                0.5,
493                Some(&[1, 3, 3, 3, 6, 9, 12][..]),
494                false,
495                -0.5,
496            ),
497            (
498                "type change at the live edge",
499                None,
500                0.5,
501                Some(&[1, 3, 6, 9, 9, 9][..]),
502                true,
503                0.5,
504            ),
505        ] {
506            let mut state = ChartState::new(BarData::from_bars(bars(&(0..10).collect::<Vec<_>>())));
507            state.set_elements(old.map(|times| Arc::from(bars(times))), true);
508            state.time_scale_mut().set_right_offset(offset);
509            state.set_elements(new.map(|times| Arc::from(bars(times))), follow);
510            assert_eq!(state.time_scale().right_offset(), expected, "{name}");
511        }
512    }
513
514    /// Flat visible data must not collapse the auto range to a near-zero span,
515    /// which would put any other price (a drawing anchor) ~1e14 px away.
516    #[test]
517    fn flat_data_auto_scales_relative_to_price_level() {
518        for (mode, price) in [
519            (PriceScaleMode::Normal, 100.0),
520            (PriceScaleMode::Logarithmic, 1.0001),
521        ] {
522            let bars = (0..50)
523                .map(|i| {
524                    let t = Utc.timestamp_opt(1_700_000_000 + i * 60, 0).unwrap();
525                    Bar::new(t, price, price, price, price, 1.0)
526                })
527                .collect();
528            let mut state = ChartState::new(BarData::from_bars(bars));
529            state.time_scale_mut().set_width(800.0);
530            state.price_scale_mut().options_mut().mode = mode;
531            let transform = state.fit_price_scale(std::iter::empty());
532            let range = state.price_scale().range();
533            let (min, max) = (transform.to_price(range.min), transform.to_price(range.max));
534            assert!(min < price && price < max, "{mode:?}: ({min}, {max})");
535            // At least a ten-thousandth of the price (one cent at 100), at most the price itself.
536            assert!(
537                (price * 1e-4..=price).contains(&(max - min)),
538                "{mode:?}: ({min}, {max})"
539            );
540        }
541    }
542
543    /// A range scaled by hand keeps every price at its pixel while the chart scrolls,
544    /// and the zoom history restores the prices it saved, although the first visible
545    /// close the relative modes are measured from changes with the scroll.
546    #[test]
547    fn manual_range_keeps_its_prices_while_the_base_moves() {
548        for mode in [PriceScaleMode::Percentage, PriceScaleMode::IndexedTo100] {
549            let bars = (0..300)
550                .map(|i| {
551                    let t = Utc.timestamp_opt(1_700_000_000 + i * 60, 0).unwrap();
552                    let close = 100.0 + i as f64;
553                    Bar::new(t, close, close, close, close, 1.0)
554                })
555                .collect();
556            let mut state = ChartState::new(BarData::from_bars(bars));
557            state.time_scale_mut().set_width(800.0);
558            state.price_scale_mut().options_mut().mode = mode;
559            let y_of_380 = |state: &mut ChartState| {
560                let transform = state.fit_price_scale(std::iter::empty());
561                let map = state.price_scale().map(transform, 0.0, 500.0);
562                map.price_to_y(380.0)
563            };
564            let zoom_in = |state: &mut ChartState| {
565                let range = state.price_scale().range();
566                let centre = (range.min + range.max) / 2.0;
567                state.price_scale_mut().zoom(0.9, centre);
568            };
569            y_of_380(&mut state);
570            zoom_in(&mut state);
571            let y = y_of_380(&mut state);
572            state.push_zoom_state();
573
574            let offset = state.time_scale().right_offset();
575            state.time_scale_mut().set_right_offset(offset - 10.0);
576            let panned = y_of_380(&mut state);
577            assert!(
578                (panned - y).abs() < 0.5,
579                "{mode:?} panned: {panned}, was {y}"
580            );
581
582            zoom_in(&mut state);
583            state.pop_zoom_state();
584            let restored = y_of_380(&mut state);
585            assert!(
586                (restored - y).abs() < 0.5,
587                "{mode:?} restored: {restored}, was {y}"
588            );
589        }
590    }
591
592    /// 800 points at 8 a bar span 100 slots, so with the right edge on bar `right` the slots of
593    /// bars `right - 99` to `right` are in view: bars 0 to 30 for bar 30, and no bar for bar -50,
594    /// before the first bar, or bar 450, after the last.
595    #[test]
596    fn visible_range_holds_the_bars_on_screen_past_either_end() {
597        let bars: Vec<_> = (0..300)
598            .map(|i| Bar::from_value(Utc.timestamp_opt(1_700_000_000 + i * 60, 0).unwrap(), 1.0))
599            .collect();
600        let mut state = ChartState::new(BarData::from_bars(bars.clone()));
601        state.time_scale_mut().set_width(800.0);
602        state.time_scale_mut().set_bar_spacing(8.0);
603        for (right, start, end) in [(30.0, 0, 31), (-50.0, 0, 0), (450.0, 300, 300)] {
604            state.time_scale_mut().set_right_offset(right - 299.0);
605            assert_eq!(state.visible_range(), (start, end), "right edge on {right}");
606            assert_eq!(
607                state.visible_data(),
608                &bars[start..end],
609                "right edge on {right}"
610            );
611        }
612    }
613}