Skip to main content

egui_charts/widget/
builder.rs

1//! Chart construction, configuration, and data management methods.
2//!
3//! This module contains [`ChartBuilder`], the builder-pattern constructors and
4//! the mutable configuration API for [`Chart`]. Use these methods to create a
5//! chart, set its initial state, and update it across frames.
6//!
7//! # Builder Pattern
8//!
9//! ```rust
10//! # let bars: Vec<egui_charts::model::Bar> = (0..100).map(|i| { let p = 100.0 + (i as f64 * 0.2).sin() * 5.0; egui_charts::model::Bar::new(chrono::DateTime::from_timestamp(1_700_000_000 + i * 60, 0).unwrap(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
11//! # let bar_data = egui_charts::model::BarData::from_bars(bars.clone());
12//! use egui_charts::config::ChartOptions;
13//! use egui_charts::widget::Chart;
14//!
15//! # let options = ChartOptions::default();
16//! let chart = Chart::new(bar_data)
17//!     .visible_bars(120)
18//!     .start_idx(0)
19//!     .with_chart_options(options);
20//! ```
21//!
22//! # Persistent Chart (Recommended for Real-Time)
23//!
24//! ```rust
25//! # use egui_charts::{ChartType, model::Bar, widget::{Chart, SeriesId}};
26//! # struct App { chart: Chart }
27//! # impl App {
28//! # fn on_frame(&mut self, bar: Bar) {
29//! // Store in your app struct, update each frame:
30//! self.chart.update(SeriesId::MAIN, bar);
31//! self.chart.set_chart_type(ChartType::Candles);
32//! self.chart.enable_tracking_mode();
33//! # }
34//! # }
35//! ```
36
37use crate::chart::selection::SelectionState;
38use crate::config::{ChartConfig, ChartOptions, RealtimeButtonPos};
39use crate::data::{Datafeed, FeedState};
40#[cfg(feature = "drawings")]
41use crate::drawings::DrawingManager;
42use crate::model::{BarClock, BarData, Timeframe};
43use crate::model::{ChartType, ChartTypeParams};
44use crate::scales::{PriceMap, PriceRange, PriceTransform};
45use crate::studies::ChartState;
46use crate::studies::IndicatorRegistry;
47use crate::theme::Theme;
48#[cfg(feature = "drawings")]
49use crate::widget::tool_interaction::cursor_modes::CursorModeState;
50
51use super::Chart;
52use super::state::{BoxZoomState, KineticScrollState};
53use std::collections::BTreeMap;
54use std::sync::Arc;
55
56impl Chart {
57    /// Creates a new chart with default configuration.
58    ///
59    /// Initializes the chart with the provided OHLCV data and sensible defaults:
60    /// candlestick chart type, default bar spacing and tracking mode off. The
61    /// bars are cleaned as by [`Chart::set_data`].
62    ///
63    /// # Arguments
64    ///
65    /// * `data` -- The OHLCV bar data to display
66    pub fn new(data: BarData) -> Self {
67        let chart_options = ChartOptions::default();
68        let (bars, rejected_bars) = super::series::clean(data.bars, &BarClock::exact());
69        let mut state = ChartState::new(BarData::from_bars(bars));
70
71        // Initialize TimeScale with defaults (apply all constraints)
72        {
73            let ts_opt = &chart_options.time_scale;
74            state.time_scale_mut().apply_options(
75                ts_opt.bar_spacing,
76                ts_opt.min_bar_spacing,
77                ts_opt.max_bar_spacing,
78                ts_opt.fix_left_edge,
79                ts_opt.fix_right_edge,
80                ts_opt.right_offset,
81                ts_opt.right_offset_pixels,
82            );
83        }
84
85        Self {
86            state,
87            replay: None,
88            market_replay: None,
89            config: ChartConfig::default(),
90            applied_config_scale_mode: Default::default(),
91            start_idx: 0,
92            chart_options,
93            keyboard: super::keyboard::KeyboardState::default(),
94            kinetic_scroll: KineticScrollState::new(),
95            scroll_start_pos: None,
96            scroll_start_offset: None,
97            prev_width: None,
98            desired_visible_bars: None,
99            last_visible_bars: 0,
100            apply_visible_bars_once: false,
101            price_axis_drag: None,
102            pending_start_idx: None,
103            pending_scroll_time: None,
104            scroll_animation: None,
105            time_axis_rect: egui::Rect::NOTHING,
106            chart_type: ChartType::Candles,
107            chart_type_params: ChartTypeParams::default(),
108            #[cfg(feature = "studies")]
109            trade_profiles: Default::default(),
110            pending_box_sizes: [false; 4],
111            older_bars_drawn: true,
112            tracking_mode_active: false,
113            mouse_in_chart: false,
114            feed: FeedState {
115                rejected_bars,
116                ..FeedState::default()
117            },
118            from_ticks: BTreeMap::new(),
119            bar_limits: Default::default(),
120            studies: IndicatorRegistry::new(),
121            study_data: Default::default(),
122            element_studies: Default::default(),
123            execution_ranges: Default::default(),
124            #[cfg(feature = "studies")]
125            volume_setting: false,
126            #[cfg(feature = "studies")]
127            volume_study: None,
128            main_series_visible: true,
129            main_series_present: true,
130            main_series_scale: Default::default(),
131            price_series: Vec::new(),
132            heikin_ashi: Default::default(),
133            series_scales: Default::default(),
134            study_ids: Vec::new(),
135            study_limit: None,
136            study_inputs: Vec::new(),
137            study_autoscale: Vec::new(),
138            pending_from: Vec::new(),
139            next_refresh: Vec::new(),
140            // 0 and 1 are the main and volume series.
141            next_series: 2,
142            primitives: Vec::new(),
143            next_primitive: 0,
144            #[cfg(feature = "drawings")]
145            drawings: DrawingManager::new(),
146            #[cfg(feature = "drawings")]
147            symbol_drawings: Default::default(),
148            #[cfg(not(feature = "drawings"))]
149            symbol_markers: Default::default(),
150            #[cfg(feature = "drawings")]
151            drawing_store: None,
152            #[cfg(feature = "drawings")]
153            restored_studies: Default::default(),
154            history: super::history::ChartHistory::default(),
155            pane_layout: super::pane::PaneLayout::default(),
156            pane_options: Default::default(),
157            box_zoom: BoxZoomState::new(),
158            zoom_mode_active: false,
159            zoom_just_applied: false,
160            symbol: String::new(),
161            legend_instrument: None,
162            currency_choices: Vec::new(),
163            currency_choice: None,
164            legend_badge: None,
165            watermark: None,
166            legend_statuses: Vec::new(),
167            quote: None,
168            quote_action: None,
169            server_time_offset: None,
170            bar_close_time: None,
171            indicator_settings_request: None,
172            session_quote: None,
173            session_provider: None,
174            horizontal_scale: None,
175            locale: None,
176            date_format: None,
177            time_formatter: None,
178            price_formatter: None,
179            percentage_formatter: None,
180            volume_formatter: None,
181            series_formatters: Default::default(),
182            extended_hours: false,
183            conversion: None,
184            measured_axis_width: 0.0,
185            last_layout: None,
186            last_ratio_lock: false,
187            axis_hover: None,
188            scale_settings_requested: false,
189            theme_override: None,
190            inherited_theme: None,
191            restored_palettes: Vec::new(),
192            host_events: Default::default(),
193            indicator_source_request: None,
194            events: Default::default(),
195            time_scale_events: Default::default(),
196            frozen_source_events: 0,
197            event_visibility: Default::default(),
198            mark_source: None,
199            #[cfg(feature = "drawings")]
200            post_source: None,
201            signal_sources: Vec::new(),
202            targets: Default::default(),
203            show_price_targets: false,
204            hidden_signal_categories: Default::default(),
205            event_click: None,
206            mark_hits: std::cell::RefCell::default(),
207            clickable_marks: Vec::new(),
208            timeframe: None,
209            feed_link: None,
210            #[cfg(feature = "drawings")]
211            cursor_modes: CursorModeState::new(),
212            main_series_style: crate::model::SeriesStyle::default(),
213            last_price_pulse: std::cell::Cell::new(None),
214            last_price_map: PriceMap::new(
215                PriceTransform::default(),
216                PriceRange::new(0.0, 100.0),
217                0.0,
218                0.0,
219                false,
220            ),
221            last_rendered_price_rect: egui::Rect::NOTHING,
222            last_rendered_volume_rect: egui::Rect::NOTHING,
223            last_rendered_indicator_panes: Vec::new(),
224            // Multi-chart sync
225            layout_inactive: false,
226            synced_crosshair_bar_idx: None,
227            synced_crosshair_period: None,
228            synced_crosshair_price: None,
229            last_crosshair: None,
230            last_hover_bar_idx: None,
231            // Marks (Widget API)
232            marks: Default::default(),
233            marker_options: Default::default(),
234            change_marks: None,
235            price_lines: Vec::new(),
236            next_price_line: 0,
237            #[cfg(feature = "ui")]
238            trading_tags: Vec::new(),
239            #[cfg(feature = "ui")]
240            trading_outlined: Vec::new(),
241            #[cfg(feature = "ui")]
242            trading_bid_ask: false,
243            #[cfg(feature = "ui")]
244            order_quantity: None,
245            #[cfg(feature = "ui")]
246            quantity_clicked: None,
247            // Selection (Widget API)
248            selection: SelectionState::new(),
249            right_click: None,
250            menu_open: false,
251            size: None,
252            merge_groups: Default::default(),
253        }
254    }
255
256    /// Creates a new chart with a custom visual configuration.
257    ///
258    /// Use this when you need to control colors, padding, grid visibility,
259    /// volume display, and other visual aspects from the start.
260    ///
261    /// # Arguments
262    ///
263    /// * `data` -- The OHLCV bar data to display
264    /// * `config` -- Custom visual configuration (colors, padding, feature flags)
265    pub fn with_config(data: BarData, config: ChartConfig) -> Self {
266        let mut chart = Self::new(data);
267        chart.update_config(config);
268        chart
269    }
270
271    /// Sets chart behavior options (builder pattern).
272    ///
273    /// Controls bar spacing, scroll/zoom constraints, right offset, and
274    /// time scale behavior. Applies the time scale options immediately. A non-finite
275    /// `bar_spacing`, `right_offset` or `right_offset_pixels` keeps its previous value, and a
276    /// `kinetic_scroll.damping_coeff` outside `0.0..1.0` is replaced by the default.
277    pub fn with_chart_options(mut self, options: ChartOptions) -> Self {
278        let options = self.normalize_chart_options(options);
279        {
280            let ts_opt = &options.time_scale;
281            self.state.time_scale_mut().apply_options(
282                ts_opt.bar_spacing,
283                ts_opt.min_bar_spacing,
284                ts_opt.max_bar_spacing,
285                ts_opt.fix_left_edge,
286                ts_opt.fix_right_edge,
287                ts_opt.right_offset,
288                ts_opt.right_offset_pixels,
289            );
290        }
291        self.chart_options = options;
292        self
293    }
294
295    /// Sets the desired number of visible bars (builder pattern).
296    ///
297    /// The actual bar spacing is computed during rendering to fit `count` bars
298    /// in the available width. Also sets the start index to show the latest
299    /// `count` bars by default.
300    pub fn visible_bars(mut self, count: usize) -> Self {
301        // Store the desired number so that we can compute the proper
302        // bar spacing during `show_internal` when we know the widget width.
303        self.desired_visible_bars = Some(count);
304        self.apply_visible_bars_once = true;
305        // Default to showing the latest `count` bars unless caller overrides later
306        self.start_idx = self.state.data().len().saturating_sub(count);
307        self
308    }
309
310    /// Sets the starting bar index for the visible range (builder pattern).
311    ///
312    /// On price-based types indices count elements; index `0` is the oldest kept element.
313    /// Otherwise index `0` is the oldest bar in the data. Use this to start the chart
314    /// at a specific point in history rather than at the latest data.
315    pub fn start_idx(mut self, index: usize) -> Self {
316        self.start_idx = index;
317        self
318    }
319
320    /// Returns the number of visible slots, counting elements on price-based types.
321    ///
322    /// This value is computed from the bar spacing and widget width during
323    /// rendering. Returns 100 as a fallback if the chart has not been rendered yet.
324    pub fn get_visible_bars(&self) -> usize {
325        // Return the last computed value (updated during `show_internal`).
326        // Falls back to width/bar_spacing semantics if `show_internal` has
327        // not been called yet.
328        if self.last_visible_bars == 0 {
329            100
330        } else {
331            self.last_visible_bars
332        }
333    }
334
335    /// Returns the current starting index of the visible range, counting elements on price-based types.
336    ///
337    /// Useful for syncing chart position with external app state or persisting
338    /// the user's scroll position across sessions.
339    pub fn get_start_idx(&self) -> usize {
340        self.start_idx
341    }
342
343    /// The configured mode of the axis the main price scale is drawn on.
344    pub(super) fn config_scale_mode(&self) -> crate::scales::PriceScaleMode {
345        if self.config.show_left_axis && !self.config.show_right_axis {
346            self.config.left_axis_scale_mode
347        } else {
348            self.config.right_axis_scale_mode
349        }
350    }
351
352    /// Applies a changed configuration mode without overriding direct scale controls.
353    pub(super) fn apply_config_scale_mode(&mut self) {
354        let mode = self.config_scale_mode();
355        if self.applied_config_scale_mode != mode {
356            if self.state.price_scale().options().mode != mode {
357                self.state.price_scale_mut().reset();
358                self.state.clear_zoom_history();
359                self.state.price_scale_mut().options_mut().mode = mode;
360            }
361            self.applied_config_scale_mode = mode;
362        }
363    }
364
365    /// Replaces the visual configuration on a persistent chart instance.
366    ///
367    /// Use this when reconfiguring a long-lived chart (e.g., after the user
368    /// changes settings). For initial construction prefer [`Chart::with_config`].
369    pub fn update_config(&mut self, config: ChartConfig) {
370        self.config = config;
371        self.applied_config_scale_mode = self.state.price_scale().options().mode;
372        self.apply_config_scale_mode();
373    }
374
375    /// Updates the desired number of visible bars on a persistent chart instance.
376    ///
377    /// Only triggers a recalculation if `count` differs from the current value.
378    /// The new bar spacing is applied on the next frame.
379    pub fn set_visible_bars(&mut self, count: usize) {
380        // Only reapply if requested value differs from last computed
381        if self.last_visible_bars == 0 || count != self.last_visible_bars {
382            self.desired_visible_bars = Some(count);
383            self.apply_visible_bars_once = true;
384        }
385    }
386
387    /// Updates the starting bar index on a persistent chart instance.
388    ///
389    /// Only triggers an update if `index` differs from the current value.
390    /// The viewport shift is applied on the next frame. On price-based types `index` counts
391    /// elements, with zero the oldest kept element.
392    pub fn set_start_idx(&mut self, index: usize) {
393        if index != self.start_idx {
394            self.start_idx = index;
395            self.pending_start_idx = Some(index);
396            self.pending_scroll_time = None;
397        }
398    }
399
400    /// Scrolls the bar nearest `time` to the centre of the chart, keeping the zoom.
401    ///
402    /// Applied on the next frame. A time outside the data shows the nearest end; with a feed,
403    /// older history is requested until the time is reached or history ends. Panning, zooming,
404    /// [`Chart::set_start_idx`] or tracking cancel it.
405    pub fn scroll_to_time(&mut self, time: chrono::DateTime<chrono::Utc>) {
406        self.pending_scroll_time = Some(time);
407        self.pending_start_idx = None;
408        self.disable_tracking_mode();
409        self.kinetic_scroll = KineticScrollState::new();
410    }
411
412    /// Sets the chart type (Candles, Bars, Line, Area, Renko, Kagi).
413    ///
414    /// Takes effect on the next rendered frame. Price-based chart types take their
415    /// sizes from [`Chart::set_chart_type_params`]. A change clears the zoom history and the
416    /// series and drawing selection, keeping bar spacing and the time at the right edge.
417    /// A view near the live edge stays there. On Renko, Kagi, Line Break, Point and Figure and
418    /// range bar charts, indices count elements instead of bars.
419    pub fn set_chart_type(&mut self, chart_type: ChartType) {
420        if self.chart_type != chart_type {
421            self.state.clear_zoom_history();
422            self.selection.deselect();
423            #[cfg(feature = "drawings")]
424            self.drawings.deselect();
425            self.chart_type = chart_type;
426        }
427    }
428
429    /// Enables tracking mode, which automatically scrolls to keep the latest bar visible.
430    ///
431    /// When enabled, the chart immediately scrolls to the live edge and stays
432    /// there as new data arrives. Disable with [`Chart::disable_tracking_mode`].
433    pub fn enable_tracking_mode(&mut self) {
434        self.tracking_mode_active = true;
435        self.pending_scroll_time = None;
436        // Immediately scroll to latest when enabling
437        self.state.time_scale_mut().scroll_to_realtime();
438    }
439
440    /// Disables tracking mode, allowing the user to scroll freely through history.
441    pub fn disable_tracking_mode(&mut self) {
442        self.tracking_mode_active = false;
443    }
444
445    /// Toggles tracking mode on or off.
446    pub fn toggle_tracking_mode(&mut self) {
447        if self.tracking_mode_active {
448            self.disable_tracking_mode();
449        } else {
450            self.enable_tracking_mode();
451        }
452    }
453
454    /// Returns whether tracking mode is currently active
455    pub fn is_tracking_mode_active(&self) -> bool {
456        self.tracking_mode_active
457    }
458
459    /// Returns the current chart type (Candles, Bars, Line, Area, Renko, or Kagi).
460    pub fn chart_type(&self) -> ChartType {
461        self.chart_type
462    }
463
464    /// What a Range chart's bars are built from, or `None` for another chart type. The chart
465    /// builds them from the trades the feed supplied, including during replay up to its cutoff,
466    /// and falls back to the OHLC approximation while it holds none.
467    pub fn range_bar_source(&self) -> Option<crate::model::RangeBarSource> {
468        use crate::model::RangeBarSource::{Executions, OhlcApproximation};
469        (self.chart_type == ChartType::Range).then(|| {
470            if self.execution_ranges.in_use() {
471                Executions
472            } else {
473                OhlcApproximation
474            }
475        })
476    }
477
478    /// Sets the parameters of the price-based chart types (Renko, Kagi, Range, Point and Figure,
479    /// Line Break) and of the baseline.
480    ///
481    /// Fixed sizes are in price units. ATR and percentage methods resolve from the loaded history
482    /// when this is called, or once enough bars arrive if history is not ready. They then stay
483    /// fixed through ticks, appends, corrections and prepends. Calling this again or replacing
484    /// the dataset with [`Chart::set_data`] recalculates them. A size the chart cannot use is
485    /// reported on the chart. On price-based types a change
486    /// clears the zoom history and selection and keeps the right-edge item by time and rank.
487    pub fn set_chart_type_params(&mut self, params: ChartTypeParams) {
488        if self.chart_type.is_time_independent() && self.chart_type_params != params {
489            self.state.clear_zoom_history();
490            self.selection.deselect();
491            #[cfg(feature = "drawings")]
492            self.drawings.deselect();
493        }
494        self.chart_type_params = params;
495        self.pending_box_sizes = self
496            .chart_type_params
497            .size_methods()
498            .map(|method| method != crate::model::BoxSizeMethod::Fixed);
499        self.chart_type_params
500            .resolve_sizes(&self.state.data().bars, &mut self.pending_box_sizes);
501    }
502
503    /// Returns the parameters of the price-based chart types and of the baseline.
504    pub fn chart_type_params(&self) -> &ChartTypeParams {
505        &self.chart_type_params
506    }
507
508    /// Calculates the bar spacing (pixels per bar) needed to fit the desired
509    /// number of bars in the given pixel width.
510    pub(crate) fn calculate_bar_spacing(&self, width: f32, visible_bars: usize) -> f32 {
511        if visible_bars == 0 {
512            self.chart_options.time_scale.bar_spacing
513        } else {
514            width / visible_bars as f32
515        }
516    }
517
518    /// Get the price rect used for actual rendering
519    ///
520    /// This is the actual screen rect where candles are drawn, useful for
521    /// external code that needs to draw overlays (e.g., selection dots).
522    pub fn get_rendered_price_rect(&self) -> egui::Rect {
523        self.last_rendered_price_rect
524    }
525
526    /// Width in points of the main price axis on `side` at the last frame, 0 when that axis is
527    /// hidden: the configured minimum width, or wider when the labels need more room.
528    pub fn price_axis_width(&self, side: crate::scales::PriceScaleId) -> f32 {
529        let (shown, minimum) = match side {
530            crate::scales::PriceScaleId::Right => {
531                (self.config.show_right_axis, self.config.right_axis_width)
532            }
533            crate::scales::PriceScaleId::Left => {
534                (self.config.show_left_axis, self.config.left_axis_width)
535            }
536        };
537        if shown {
538            minimum.max(self.measured_axis_width)
539        } else {
540            0.0
541        }
542    }
543
544    /// Get the volume rect used for actual rendering
545    ///
546    /// This is the actual screen rect where volume bars are drawn, useful for
547    /// external code that needs to draw overlays (e.g., selection dots).
548    pub fn get_rendered_volume_rect(&self) -> egui::Rect {
549        self.last_rendered_volume_rect
550    }
551
552    /// Get the rendered indicator pane info for hit testing
553    ///
554    /// This returns information about each rendered indicator pane, useful for
555    /// external code that needs to do hit testing on indicator lines.
556    pub fn get_rendered_indicator_panes(&self) -> &[super::RenderedIndicatorPane] {
557        &self.last_rendered_indicator_panes
558    }
559}
560
561/// Fluent builder for a [`Chart`].
562///
563/// Use one of the constructor methods ([`new`](Self::new),
564/// [`price_chart`](Self::price_chart), [`options_chart`](Self::options_chart)) and chain
565/// configuration calls before calling [`build`](Self::build) to produce the chart.
566///
567/// All setter methods return `Self` and are marked `#[must_use]` to prevent accidental
568/// drops of the builder mid-chain.
569///
570/// # Examples
571///
572/// ```rust
573/// use egui_charts::{ChartBuilder, ChartType};
574/// use egui_charts::model::Timeframe;
575/// use egui_charts::theme::Theme;
576///
577/// let chart = ChartBuilder::new()
578///     .with_symbol("ETHUSDT")
579///     .with_timeframe(Timeframe::Hour1)
580///     .with_theme(Theme::dark())
581///     .with_chart_type(ChartType::Candles)
582///     .with_visible_candles(120)
583///     .build();
584/// ```
585pub struct ChartBuilder {
586    feed: Option<Arc<dyn Datafeed>>,
587    symbol: Option<String>,
588    timeframe: Timeframe,
589    theme: Option<Theme>,
590    chart_type: ChartType,
591    config: ChartConfig,
592    chart_options: ChartOptions,
593    indicators: Option<IndicatorRegistry>,
594    #[cfg(feature = "drawings")]
595    drawing_registry: crate::drawings::DrawingToolRegistry,
596    visible_candles: usize,
597    /// Bars asked for in the first page of history.
598    initial_bars: usize,
599    locale: Option<String>,
600    time_formatter: Option<Arc<dyn crate::scales::TimeFormatter>>,
601    price_formatter: Option<Arc<dyn crate::scales::PriceFormatter>>,
602    percentage_formatter: Option<Arc<dyn crate::scales::PriceFormatter>>,
603    volume_formatter: Option<Arc<dyn crate::scales::PriceFormatter>>,
604    #[cfg(feature = "drawings")]
605    storage: Option<Arc<dyn crate::services::Storage>>,
606    #[cfg(feature = "ui")]
607    study_factory: Option<crate::studies::IndicatorFactory>,
608}
609
610impl ChartBuilder {
611    /// Create a new chart builder with sensible defaults
612    ///
613    /// With a feed and a symbol, the chart asks for the latest 1000 bars at its
614    /// first `show`, and again when the symbol or timeframe changes.
615    pub fn new() -> Self {
616        Self {
617            feed: None,
618            symbol: None,
619            timeframe: Timeframe::Min1,
620            theme: None,
621            chart_type: ChartType::Candles,
622            config: ChartConfig::default(),
623            chart_options: ChartOptions::default(),
624            indicators: None,
625            #[cfg(feature = "drawings")]
626            drawing_registry: Default::default(),
627            visible_candles: 100,
628            initial_bars: 1000,
629            locale: None,
630            time_formatter: None,
631            price_formatter: None,
632            percentage_formatter: None,
633            volume_formatter: None,
634            #[cfg(feature = "drawings")]
635            storage: None,
636            #[cfg(feature = "ui")]
637            study_factory: None,
638        }
639    }
640
641    /// Creates a line chart with the time axis labels and ticks hidden.
642    ///
643    /// The data is still a time series of bars, and pan and zoom work as on any
644    /// chart.
645    ///
646    /// # Examples
647    ///
648    /// ```rust
649    /// use egui_charts::ChartBuilder;
650    ///
651    /// // Line chart without time axis labels
652    /// let chart = ChartBuilder::options_chart()
653    ///     .with_symbol("SPY")
654    ///     .build();
655    /// ```
656    ///
657    /// # Note
658    ///
659    /// For traditional OHLC price charts, use [`ChartBuilder::new()`] instead.
660    pub fn options_chart() -> Self {
661        let mut chart_options = ChartOptions::default();
662        // Disable time-based features
663        chart_options.time_scale.time_visible = false;
664        chart_options.time_scale.seconds_visible = false;
665        chart_options.time_scale.ticks_visible = false;
666
667        Self::new()
668            .with_config(ChartConfig {
669                show_time_labels: false,
670                ..Default::default()
671            })
672            .with_chart_options(chart_options)
673            .with_chart_type(ChartType::Line)
674    }
675
676    /// Creates a line chart without grid or crosshair, showing 50 bars.
677    ///
678    /// Drawing tools and studies are not added.
679    ///
680    /// # Examples
681    ///
682    /// ```rust
683    /// use egui_charts::ChartBuilder;
684    ///
685    /// // Create minimal price ticker
686    /// let chart = ChartBuilder::price_chart()
687    ///     .with_symbol("BTCUSDT")
688    ///     .with_visible_candles(30)  // Even narrower view
689    ///     .build();
690    ///
691    /// // Clean chart with no grid, crosshair, or tools
692    /// ```
693    ///
694    /// ## Dashboard Example
695    ///
696    /// ```rust
697    /// use egui_charts::ChartBuilder;
698    ///
699    /// // Multiple mini-charts for dashboard
700    /// let btc_chart = ChartBuilder::price_chart()
701    ///     .with_symbol("BTCUSDT")
702    ///     .build();
703    ///
704    /// let eth_chart = ChartBuilder::price_chart()
705    ///     .with_symbol("ETHUSDT")
706    ///     .build();
707    /// ```
708    ///
709    /// # Note
710    ///
711    /// For drawing tools and studies, use [`ChartBuilder::new()`] instead.
712    pub fn price_chart() -> Self {
713        use crate::config::CrosshairOptions;
714
715        let config = ChartConfig {
716            show_horizontal_grid: false,
717            show_vertical_grid: false,
718            ..ChartConfig::default()
719        };
720
721        let chart_options = ChartOptions {
722            crosshair: CrosshairOptions {
723                vert_line_visible: false,
724                horz_line_visible: false,
725                label_visible: false,
726                ..CrosshairOptions::default()
727            },
728            ..ChartOptions::default()
729        };
730
731        Self::new()
732            .with_config(config)
733            .with_chart_options(chart_options)
734            .with_chart_type(ChartType::Line)
735            .with_visible_candles(50)
736    }
737
738    /// Source of instrument, history and live updates. Without one, feed the chart with
739    /// [`Chart::set_data`] and [`Chart::update`].
740    ///
741    /// [`build`](Self::build) calls no feed method: with a symbol, the chart's first
742    /// [`show`](Chart::show) asks the feed for the instrument and the latest page of history,
743    /// and subscribes once the instrument resolves.
744    #[must_use]
745    pub fn with_feed(mut self, feed: Arc<dyn Datafeed>) -> Self {
746        self.feed = Some(feed);
747        self
748    }
749
750    /// Set the trading symbol (e.g., `"BTCUSDT"`, `"AAPL"`).
751    ///
752    /// Without a symbol the chart shows none and requests nothing from the
753    /// feed.
754    #[must_use]
755    pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
756        self.symbol = Some(symbol.into());
757        self
758    }
759
760    /// Set the bar aggregation timeframe (default: `Timeframe::Min1`).
761    #[must_use]
762    pub fn with_timeframe(mut self, timeframe: Timeframe) -> Self {
763        self.timeframe = timeframe;
764        self
765    }
766
767    /// Set a per-chart theme. Without one the chart follows the active egui theme.
768    ///
769    /// Applies colours to the [`ChartConfig`] and retains a per-chart override for text sizes,
770    /// axes, legends and overlays. It leaves egui and sibling charts unchanged.
771    /// Use [`Chart::set_theme_override`] to replace or clear it on a persistent chart.
772    #[must_use]
773    pub fn with_theme(mut self, theme: Theme) -> Self {
774        self.theme = Some(theme);
775        self
776    }
777
778    /// Set the chart visualization type (default: `ChartType::Candles`).
779    ///
780    /// Common types: `Candles`, `Line`, `Area`, `Bar`, `Baseline`, `Histogram`.
781    #[must_use]
782    pub fn with_chart_type(mut self, chart_type: ChartType) -> Self {
783        self.chart_type = chart_type;
784        self
785    }
786
787    /// Override the entire [`ChartConfig`] (axis visibility, grid, realtime button, etc.).
788    #[must_use]
789    pub fn with_config(mut self, config: ChartConfig) -> Self {
790        self.config = config;
791        self
792    }
793
794    /// Override the [`ChartOptions`] (crosshair, time scale, scroll/zoom behavior, etc.).
795    #[must_use]
796    pub fn with_chart_options(mut self, options: ChartOptions) -> Self {
797        self.chart_options = options;
798        self
799    }
800
801    /// Toggle session-break dividers (day/week/month boundaries) and shading.
802    #[must_use]
803    pub fn with_session_breaks(mut self, show: bool) -> Self {
804        self.config.show_session_breaks = show;
805        self
806    }
807
808    /// Add the studies of `registry` (SMA, RSI, MACD, etc.) to the chart, which computes,
809    /// updates and draws them.
810    #[must_use]
811    pub fn with_indicators(mut self, registry: IndicatorRegistry) -> Self {
812        self.indicators = Some(registry);
813        self
814    }
815
816    /// Set host drawing tools and built-in availability before the chart is built.
817    #[cfg(feature = "drawings")]
818    #[cfg_attr(docsrs, doc(cfg(feature = "drawings")))]
819    #[must_use]
820    pub fn with_drawing_registry(mut self, registry: crate::drawings::DrawingToolRegistry) -> Self {
821        self.drawing_registry = registry;
822        self
823    }
824
825    /// Name months and weekdays in `locale`'s language, as [`Chart::set_locale`] does.
826    #[must_use]
827    pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
828        self.locale = Some(locale.into());
829        self
830    }
831
832    /// Format time labels, as [`Chart::set_time_formatter`] does.
833    #[must_use]
834    pub fn with_time_formatter(mut self, formatter: Arc<dyn crate::scales::TimeFormatter>) -> Self {
835        self.time_formatter = Some(formatter);
836        self
837    }
838
839    /// Format price labels, as [`Chart::set_price_formatter`] does.
840    #[must_use]
841    pub fn with_price_formatter(
842        mut self,
843        formatter: Arc<dyn crate::scales::PriceFormatter>,
844    ) -> Self {
845        self.price_formatter = Some(formatter);
846        self
847    }
848
849    /// Format percentage and indexed price labels, as [`Chart::set_percentage_formatter`] does.
850    #[must_use]
851    pub fn with_percentage_formatter(
852        mut self,
853        formatter: Arc<dyn crate::scales::PriceFormatter>,
854    ) -> Self {
855        self.percentage_formatter = Some(formatter);
856        self
857    }
858
859    /// Format volumes, as [`Chart::set_volume_formatter`] does.
860    #[must_use]
861    pub fn with_volume_formatter(
862        mut self,
863        formatter: Arc<dyn crate::scales::PriceFormatter>,
864    ) -> Self {
865        self.volume_formatter = Some(formatter);
866        self
867    }
868
869    /// Keep the drawings of each symbol in `storage`, as [`Chart::set_storage`] does.
870    #[cfg(feature = "drawings")]
871    #[cfg_attr(docsrs, doc(cfg(feature = "drawings")))]
872    #[must_use]
873    pub fn with_storage(mut self, storage: Arc<dyn crate::services::Storage>) -> Self {
874        self.storage = Some(storage);
875        self
876    }
877
878    /// Build the studies of a saved chart passed to [`build_restored`](Self::build_restored)
879    /// through `factory`, so that studies it registers are restored. Without one only the
880    /// built-in studies are.
881    #[cfg(feature = "ui")]
882    #[cfg_attr(docsrs, doc(cfg(feature = "ui")))]
883    #[must_use]
884    pub fn with_study_factory(mut self, factory: crate::studies::IndicatorFactory) -> Self {
885        self.study_factory = Some(factory);
886        self
887    }
888
889    /// Set the number of bars visible in the initial viewport (default: 100).
890    ///
891    /// This controls the initial bar spacing so that approximately `count` bars
892    /// fit within the chart width. Use zero to keep the configured bar spacing.
893    #[must_use]
894    pub fn with_visible_candles(mut self, count: usize) -> Self {
895        self.visible_candles = count;
896        self
897    }
898
899    /// Bars asked for in the first page of history (default 1,000); scroll-back pages ask for
900    /// at least 600.
901    ///
902    /// # Examples
903    /// ```
904    /// use egui_charts::ChartBuilder;
905    ///
906    /// let chart = ChartBuilder::new().with_initial_bars(500).build();
907    /// ```
908    #[must_use]
909    pub fn with_initial_bars(mut self, bars: usize) -> Self {
910        self.initial_bars = bars;
911        self
912    }
913
914    /// Configure the right price scale
915    ///
916    /// The right price scale is the primary scale, shown by default. The chart
917    /// starts with this visibility and scale mode.
918    ///
919    /// # Examples
920    /// ```
921    /// use egui_charts::ChartBuilder;
922    /// use egui_charts::scales::PriceScaleMode;
923    ///
924    /// let chart = ChartBuilder::new()
925    ///     .with_right_price_scale(true, PriceScaleMode::Logarithmic)
926    ///     .build();
927    /// ```
928    #[must_use]
929    pub fn with_right_price_scale(
930        mut self,
931        visible: bool,
932        mode: crate::scales::PriceScaleMode,
933    ) -> Self {
934        self.config.show_right_axis = visible;
935        self.config.right_axis_scale_mode = mode;
936        self
937    }
938
939    /// Enable or disable the scroll-to-latest button.
940    ///
941    /// Default: true (visible)
942    #[must_use]
943    pub fn with_realtime_btn(mut self, visible: bool) -> Self {
944        self.config.show_realtime_btn = visible;
945        self
946    }
947
948    /// Set the position of the scroll-to-latest button.
949    ///
950    /// Default: BottomRight
951    #[must_use]
952    pub fn with_realtime_button_pos(mut self, position: RealtimeButtonPos) -> Self {
953        self.config.realtime_button_pos = position;
954        self
955    }
956
957    /// Set custom text for the scroll-to-latest button. Set its size to fit the text.
958    ///
959    /// Default: a double right chevron
960    #[must_use]
961    pub fn with_realtime_button_text(mut self, text: impl Into<String>) -> Self {
962        self.config.realtime_button_text = Some(text.into());
963        self
964    }
965
966    /// Set the size of the scroll-to-latest button (width, height) in points.
967    ///
968    /// Default: (28.0, 28.0)
969    #[must_use]
970    pub fn with_realtime_button_size(mut self, width: f32, height: f32) -> Self {
971        self.config.realtime_button_size = (width, height);
972        self
973    }
974
975    /// Consume the builder and produce the chart.
976    ///
977    /// This applies the theme to the config and sets up the chart. It never
978    /// blocks and calls no feed method: with a feed and a symbol, the chart's
979    /// first [`show`](Chart::show) asks the feed for the instrument and history.
980    pub fn build(mut self) -> Chart {
981        if let Some(theme) = &self.theme {
982            self.config = self.config.with_theme(theme);
983        }
984
985        let mut chart =
986            Chart::with_config(BarData::new(), self.config).with_chart_options(self.chart_options);
987
988        chart.theme_override = self.theme;
989        chart.set_locale(self.locale);
990        chart.set_time_formatter(self.time_formatter);
991        chart.set_price_formatter(self.price_formatter);
992        chart.set_percentage_formatter(self.percentage_formatter);
993        chart.set_volume_formatter(self.volume_formatter);
994        chart.set_chart_type(self.chart_type);
995        chart.set_visible_bars(self.visible_candles);
996        chart.set_symbol(self.symbol.unwrap_or_default());
997        chart.set_timeframe(self.timeframe);
998        #[cfg(feature = "drawings")]
999        {
1000            chart.drawings.registry = self.drawing_registry;
1001            if let Some(storage) = self.storage {
1002                chart.set_storage(storage);
1003            }
1004        }
1005        if let Some(feed) = self.feed {
1006            chart.attach_feed(feed, self.initial_bars);
1007        }
1008
1009        let mut studies = self.indicators.unwrap_or_default();
1010        while let Some(study) = studies.remove_indicator(0) {
1011            chart.add_indicator(study);
1012        }
1013        chart
1014    }
1015
1016    /// Consume the builder and show the saved chart `snapshot` on the new chart.
1017    ///
1018    /// The chart is built as by [`build`](Self::build) with the locale, formatters, storage,
1019    /// feed and studies of the builder, and `snapshot` is then restored with
1020    /// [`with_study_factory`](Self::with_study_factory)'s factory, as
1021    /// [`Chart::restore_with`] does, before the chart is returned. The host then binds data to
1022    /// the restored series with [`Chart::restored_series_id`]. Returns the drawings that could
1023    /// not be read with the chart. A snapshot that cannot be restored is an error and no chart.
1024    #[cfg(feature = "ui")]
1025    #[cfg_attr(docsrs, doc(cfg(feature = "ui")))]
1026    pub fn build_restored(
1027        mut self,
1028        snapshot: &super::ChartSnapshot,
1029    ) -> Result<(Chart, Vec<crate::drawings::persistence::SkippedDrawing>), super::RestoreError>
1030    {
1031        let factory = self.study_factory.take().unwrap_or_default();
1032        let mut chart = self.build();
1033        let skipped = chart.restore_with(snapshot, &factory)?;
1034        Ok((chart, skipped))
1035    }
1036}
1037
1038impl Default for ChartBuilder {
1039    fn default() -> Self {
1040        Self::new()
1041    }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047    use crate::model::Bar;
1048    use chrono::{Duration, TimeZone, Utc};
1049
1050    /// The host's formatters and studies are in place when the saved chart is restored, and a
1051    /// saved chart that cannot be restored yields an error and no chart.
1052    #[test]
1053    #[cfg(feature = "ui")]
1054    fn build_restored_applies_bindings_then_state_or_fails_without_a_chart() {
1055        let saved = ChartBuilder::new()
1056            .with_symbol("SAVED")
1057            .with_indicators({
1058                let mut studies = IndicatorRegistry::default();
1059                studies.add_indicator(Box::new(crate::studies::SMA::new(5)));
1060                studies
1061            })
1062            .build()
1063            .snapshot()
1064            .unwrap();
1065        let formatter = Arc::new(crate::scales::CustomPriceFormatter::new(|price| {
1066            format!("host {price:.1}")
1067        }));
1068        let (chart, skipped) = ChartBuilder::new()
1069            .with_symbol("OTHER")
1070            .with_price_formatter(formatter)
1071            .build_restored(&saved)
1072            .unwrap();
1073        assert!(skipped.is_empty());
1074        assert_eq!(chart.symbol(), "SAVED");
1075        assert_eq!(chart.indicators().count(), 1);
1076        let labelled = chart
1077            .series_label_formatter(
1078                crate::widget::SeriesId::MAIN,
1079                crate::scales::PriceScaleMode::Normal,
1080            )
1081            .map(|formatter| formatter.format(1.5));
1082        assert_eq!(labelled.as_deref(), Some("host 1.5"));
1083
1084        let mut invalid = saved;
1085        invalid.bar_spacing = 0.0;
1086        assert!(ChartBuilder::new().build_restored(&invalid).is_err());
1087    }
1088
1089    #[test]
1090    #[cfg(feature = "drawings")]
1091    fn drawings_keep_times_through_replacement_streaming_and_symbol_switches() {
1092        use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
1093        use crate::widget::SeriesId;
1094        let bars = |times: &[i64]| {
1095            times
1096                .iter()
1097                .map(|&t| Bar::new(Utc.timestamp_opt(t, 0).unwrap(), 10.0, 11.0, 9.0, 10.0, 1.0))
1098                .collect()
1099        };
1100        let mut chart = Chart::new(BarData::from_bars(bars(&[60, 120, 180])));
1101        chart.set_symbol("A");
1102        chart.user_action(|chart| {
1103            let mut drawing = Drawing::new(7, DrawingToolType::TrendLine);
1104            drawing.chart_points = [-0.5, 0.5, 3.5].map(|i| ChartPoint::new(i, 10.0)).to_vec();
1105            chart.drawings_mut().drawings.push(drawing);
1106            chart
1107                .drawings_mut()
1108                .edit_drawing(7, |d| d.color = [1, 2, 3, 255]);
1109        });
1110        chart.set_markers(
1111            SeriesId::MAIN,
1112            vec![crate::model::Marker::new(Utc.timestamp_opt(90, 0).unwrap())],
1113        );
1114        // Anchors are at 30, 90, 270 seconds. New bars include an interior gap and
1115        // a new cadence: their projected indices follow piecewise linear interpolation.
1116        for (times, expected) in [
1117            (vec![0, 60, 120, 180], [0.5, 1.5, 4.5]),
1118            (vec![0, 120, 240, 360], [0.25, 0.75, 2.25]),
1119            (vec![], [0.25, 0.75, 2.25]),
1120            (vec![0, 60, 180, 300], [0.5, 1.25, 2.75]),
1121        ] {
1122            chart.set_data(SeriesId::MAIN, bars(&times));
1123            for (point, want) in chart.drawings().drawings[0]
1124                .chart_points
1125                .iter()
1126                .zip(expected)
1127            {
1128                assert_eq!(point.bar_idx, want, "times {times:?}");
1129            }
1130        }
1131        assert!(chart.undo());
1132        assert_eq!(chart.drawings().drawings[0].chart_points[1].bar_idx, 1.25);
1133        assert!(chart.redo());
1134        chart.update(SeriesId::MAIN, bars(&[120]).pop().unwrap());
1135        assert_eq!(chart.drawings().drawings[0].chart_points[1].bar_idx, 1.5);
1136        // An index edited by the host gets a new time before the next mutation.
1137        chart.drawings_mut().drawings[0].chart_points[1].bar_idx = 2.5;
1138        chart.drawings_mut().stay_in_drawing_mode = true;
1139        chart.drawings_mut().eraser_mode = true;
1140        chart.drawings_mut().start_drag_body(7, egui::Pos2::ZERO);
1141        chart.drawings_mut().registry.set_availability(
1142            DrawingToolType::TrendLine,
1143            crate::drawings::ToolAvailability {
1144                visible: false,
1145                enabled: false,
1146            },
1147        );
1148        chart.set_symbol("B");
1149        assert!(
1150            !chart
1151                .drawings()
1152                .registry
1153                .availability(DrawingToolType::TrendLine)
1154                .enabled
1155        );
1156        assert!(chart.drawings().drawings.is_empty());
1157        assert!(chart.marks.is_empty());
1158        assert!(chart.drawings().stay_in_drawing_mode);
1159        assert!(chart.drawings().eraser_mode);
1160        chart.set_data(SeriesId::MAIN, bars(&[0, 120, 240]));
1161        chart.set_symbol("A");
1162        assert!(chart.drawings().dragging_handle.is_none());
1163        chart.set_data(SeriesId::MAIN, bars(&[0, 60, 120, 180, 240, 300]));
1164        assert_eq!(chart.drawings().drawings[0].chart_points[1].bar_idx, 2.5);
1165        assert_eq!(chart.marks.len(), 1);
1166    }
1167
1168    #[test]
1169    fn configured_price_mode_reaches_constructors_and_mutations() {
1170        use crate::scales::PriceScaleMode;
1171        for entry in 0..4 {
1172            let config = ChartConfig {
1173                right_axis_scale_mode: PriceScaleMode::Logarithmic,
1174                ..Default::default()
1175            };
1176            let mut chart = match entry {
1177                0 => Chart::with_config(BarData::new(), config.clone()),
1178                1 => ChartBuilder::new().with_config(config.clone()).build(),
1179                _ => Chart::new(BarData::new()),
1180            };
1181            if entry == 2 {
1182                chart.update_config(config);
1183            } else if entry == 3 {
1184                chart.config_mut().right_axis_scale_mode = PriceScaleMode::Logarithmic;
1185            }
1186            let ctx = egui::Context::default();
1187            ctx.run_ui(Default::default(), |ui| {
1188                chart.show(ui);
1189            })
1190            .drop_without_applying_deltas();
1191            assert_eq!(
1192                chart.state.price_scale().options().mode,
1193                PriceScaleMode::Logarithmic,
1194                "entry {entry}"
1195            );
1196            chart.state.price_scale_mut().set_range(1.0, 2.0);
1197            chart.update_config(chart.config().clone());
1198            assert!(chart.state.price_scale().is_manual());
1199            chart.config_mut().right_axis_scale_mode = PriceScaleMode::Normal;
1200            ctx.run_ui(Default::default(), |ui| {
1201                chart.show(ui);
1202            })
1203            .drop_without_applying_deltas();
1204            assert_eq!(
1205                chart.state.price_scale().options().mode,
1206                PriceScaleMode::Normal
1207            );
1208            assert!(!chart.state.price_scale().is_manual());
1209            chart.state.price_scale_mut().set_range(10.0, 20.0);
1210            let range = chart.state.price_scale().range();
1211            chart.user_action(|chart| {
1212                chart.config_mut().right_axis_scale_mode = PriceScaleMode::Logarithmic;
1213            });
1214            ctx.run_ui(Default::default(), |ui| {
1215                chart.show(ui);
1216            })
1217            .drop_without_applying_deltas();
1218            assert!(chart.undo());
1219            ctx.run_ui(Default::default(), |ui| {
1220                chart.show(ui);
1221            })
1222            .drop_without_applying_deltas();
1223            assert!(
1224                chart.state.price_scale().is_manual(),
1225                "undo restores the range"
1226            );
1227            assert_eq!(chart.state.price_scale().range(), range);
1228        }
1229    }
1230
1231    /// The mode of the axis the main scale is drawn on sets the scale, whichever side that is.
1232    #[test]
1233    fn configured_mode_follows_the_axis_of_the_main_scale() {
1234        use crate::scales::PriceScaleMode::{Logarithmic, Normal, Percentage};
1235        use crate::widget::test_frames::{chart, frame};
1236        for (left, expected) in [(false, Percentage), (true, Logarithmic)] {
1237            let ctx = egui::Context::default();
1238            let mut chart = chart(&[100.0], &[110.0]);
1239            frame(&ctx, &mut chart, 0.0, Vec::new());
1240            chart.config_mut().show_left_axis = left;
1241            chart.config_mut().show_right_axis = !left;
1242            chart.config_mut().left_axis_scale_mode = Logarithmic;
1243            chart.config_mut().right_axis_scale_mode = Percentage;
1244            frame(&ctx, &mut chart, 0.1, Vec::new());
1245            assert_eq!(chart.state.price_scale().options().mode, expected);
1246            chart.config_mut().left_axis_scale_mode = Normal;
1247            chart.config_mut().right_axis_scale_mode = Normal;
1248            frame(&ctx, &mut chart, 0.2, Vec::new());
1249            assert_eq!(chart.state.price_scale().options().mode, Normal);
1250        }
1251    }
1252
1253    /// Both entry points fit 50 slots once and leave the same view at the latest bar.
1254    #[test]
1255    fn visible_bars_matches_the_setter_on_initial_frames() {
1256        let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1257        let data = BarData::from_bars(
1258            (0..600)
1259                .map(|i| Bar::new(start + Duration::days(i), 100.0, 200.0, 100.0, 200.0, 1.0))
1260                .collect(),
1261        );
1262        for width in [600.0, 900.0, 1440.0] {
1263            let mut built = Chart::new(data.clone()).visible_bars(50);
1264            let mut set = Chart::new(data.clone());
1265            set.set_visible_bars(50);
1266            let contexts = [egui::Context::default(), egui::Context::default()];
1267            built.set_timeframe(Timeframe::Day1);
1268            set.set_timeframe(Timeframe::Day1);
1269            for frame in 0..3 {
1270                for (chart, ctx) in [(&mut built, &contexts[0]), (&mut set, &contexts[1])] {
1271                    let input = egui::RawInput {
1272                        screen_rect: Some(egui::Rect::from_min_size(
1273                            egui::Pos2::ZERO,
1274                            egui::vec2(width, 500.0),
1275                        )),
1276                        time: Some(frame as f64 * 0.1),
1277                        ..Default::default()
1278                    };
1279                    ctx.run_ui(input, |ui| {
1280                        egui::CentralPanel::default().show(ui, |ui| {
1281                            chart.show(ui);
1282                        });
1283                    })
1284                    .drop_without_applying_deltas();
1285                }
1286                assert_eq!(
1287                    built.state.time_scale().bar_spacing(),
1288                    set.state.time_scale().bar_spacing(),
1289                    "width {width}, frame {frame}"
1290                );
1291                assert_eq!(
1292                    built.state.time_scale().bar_spacing(),
1293                    built.get_rendered_price_rect().width() / 50.0
1294                );
1295                assert_eq!(built.get_visible_bars(), set.get_visible_bars());
1296                assert_eq!(built.get_visible_bars(), 49);
1297                assert_eq!(built.get_start_idx(), set.get_start_idx());
1298                assert!(built.get_start_idx() >= 548);
1299            }
1300            // A later zoom survives subsequent frames: the builder's count is applied once.
1301            built.state.time_scale_mut().set_bar_spacing(20.0);
1302            built.apply_timescale_config(width - 166.0);
1303            assert_eq!(built.state.time_scale().bar_spacing(), 20.0);
1304        }
1305    }
1306}