Skip to main content

egui_charts/widget/series/
options.rs

1//! Host-fed bar series and their shared price scales.
2
3use crate::chart::rendering;
4use crate::data::FeedState;
5use crate::model::{
6    AreaStyle, Bar, BaselineStyle, ChartType, ChartTypeParams, ChartTypeStyles, LinePlotStyle,
7    LineStyle, Marker, MarkerOptions, PriceLine, SeriesScale, SeriesStyle,
8};
9use crate::scales::StudyFormat;
10use crate::scales::{PriceMap, PriceScale, PriceScaleMode};
11use crate::services::SymbolExpression;
12use crate::studies::{Indicator, IndicatorValues, PlotStyle, PlotType, StudyDataRequest};
13use crate::tokens::DESIGN_TOKENS;
14use crate::widget::{Chart, ChartElementId, PaneId, SeriesId};
15use egui::Color32;
16use serde::{Deserialize, Serialize};
17
18/// The spread a series name spells, such as `AAPL-MSFT`; `None` for a plain symbol.
19fn spread_of(name: &str) -> Option<SymbolExpression> {
20    SymbolExpression::parse(name)
21        .ok()
22        .filter(SymbolExpression::is_spread)
23}
24
25/// The symbols the feed supplies for a series name: the symbol itself, or a spread's legs.
26fn fed_symbols(name: &str) -> Vec<String> {
27    match SymbolExpression::parse(name) {
28        Ok(expression) => expression
29            .symbols()
30            .into_iter()
31            .map(str::to_owned)
32            .collect(),
33        Err(_) => vec![name.to_owned()],
34    }
35}
36
37/// Display settings of a host-fed bar series.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct SeriesOptions {
40    /// Host-supplied title, displayed beside the series values.
41    pub name: String,
42    /// How the bars are drawn. The types that replace the time axis with their own elements
43    /// (range bars, Renko, Kagi, Line Break, Point and Figure) and TPO profiles are drawn as
44    /// [`ChartType::Line`] and read back as such.
45    pub chart_type: ChartType,
46    /// Colour of the line, step, marker and area types, the legend and the last-value tag of
47    /// the types drawn from the close.
48    pub color: Color32,
49    /// Rising bars of the OHLC and column types, and the baseline type above its base.
50    pub up_color: Color32,
51    /// Falling bars of the OHLC and column types, and the baseline type below its base.
52    pub down_color: Color32,
53    /// Independent baseline line and fill styles. `None` uses `up_color`, `down_color` and
54    /// `width`, with flat fills at the default opacity.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub baseline_style: Option<BaselineStyle>,
57    /// Line width in logical pixels.
58    pub width: f32,
59    /// Point marker radius in logical points for Line with markers, Area and Baseline.
60    /// Overrides `baseline_style`'s radius. `None` keeps that style's radius or the chart
61    /// type's default: 3 for Line with markers, hidden for Area and Baseline.
62    /// Non-positive or non-finite radii hide markers.
63    #[serde(default)]
64    pub marker_radius: Option<f32>,
65    /// Scale shared with the series carrying the same assignment in the price pane. In another
66    /// pane the series uses that pane's scale.
67    pub scale: SeriesScale,
68    /// The price [`ChartType::Columns`] rise above or fall below, which the scale's fit then
69    /// includes; `None` raises them from the pane bottom.
70    pub columns_base: Option<f64>,
71    /// Numeric display format, last value label, price line, base line and autoscale. By
72    /// default the series labels its last value without a price line.
73    pub style: SeriesStyle,
74}
75
76impl Default for SeriesOptions {
77    fn default() -> Self {
78        Self {
79            name: String::new(),
80            chart_type: ChartType::Line,
81            color: Color32::from_rgb(41, 98, 255),
82            up_color: DESIGN_TOKENS.semantic.extended.bullish,
83            down_color: DESIGN_TOKENS.semantic.extended.bearish,
84            baseline_style: None,
85            width: 2.0,
86            marker_radius: None,
87            scale: SeriesScale::Right,
88            columns_base: None,
89            style: SeriesStyle {
90                price_line_visible: false,
91                ..SeriesStyle::default()
92            },
93        }
94    }
95}
96
97impl SeriesOptions {
98    fn sanitized(mut self) -> Self {
99        if !self.width.is_finite() || self.width <= 0.0 {
100            self.width = 2.0;
101        }
102        if self.chart_type.is_time_independent()
103            || self.chart_type == ChartType::TimePriceOpportunity
104        {
105            self.chart_type = ChartType::Line;
106        }
107        self
108    }
109
110    /// The line, area and baseline styles in the series colours, unless `baseline_style`
111    /// supplies an explicit baseline style, and the columns' base.
112    pub(super) fn type_params(&self) -> ChartTypeParams {
113        let line = |color| LinePlotStyle {
114            color,
115            width: self.width,
116            style: LineStyle::Solid,
117            ..Default::default()
118        };
119        let fill = |color: Color32, alpha| {
120            Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha)
121        };
122        let mut baseline = self
123            .baseline_style
124            .clone()
125            .unwrap_or_else(|| BaselineStyle {
126                above: line(self.up_color),
127                below: line(self.down_color),
128                above_fill: fill(self.up_color, 30),
129                below_fill: fill(self.down_color, 30),
130                ..Default::default()
131            });
132        if let Some(radius) = self.marker_radius {
133            baseline.marker_radius = radius;
134        }
135        ChartTypeParams {
136            styles: ChartTypeStyles {
137                line: line(self.color),
138                step_line: line(self.color),
139                line_with_markers: line(self.color),
140                marker_radius: self.marker_radius.unwrap_or(3.0),
141                area: AreaStyle {
142                    line: line(self.color),
143                    marker_radius: self.marker_radius.unwrap_or(0.0),
144                    top_fill: fill(self.color, 115),
145                    bottom_fill: Color32::TRANSPARENT,
146                    ..Default::default()
147                },
148                baseline,
149                ..Default::default()
150            },
151            columns_base: self.columns_base,
152            ..Default::default()
153        }
154    }
155}
156
157/// The base line of a percentage or indexed scale mapped by `map`, at 0% or 100.
158pub(in crate::widget) fn base_line(map: &PriceMap) -> Option<PriceLine> {
159    let transform = map.transform();
160    matches!(
161        transform.mode,
162        PriceScaleMode::Percentage | PriceScaleMode::IndexedTo100
163    )
164    .then(|| {
165        PriceLine::new(transform.base, DESIGN_TOKENS.semantic.extended.gray)
166            .with_width(DESIGN_TOKENS.stroke.hairline)
167            .with_style(LineStyle::Solid)
168            .with_axis_label(false)
169    })
170}
171
172pub(in crate::widget) struct PriceSeries {
173    pub id: SeriesId,
174    pub options: SeriesOptions,
175    pub visible: bool,
176    /// Feed ownership travels with the series through undo and redo.
177    pub fed: bool,
178    pub bars: Vec<Bar>,
179    /// Changes when bars other than the last one change, as the main bars' revision does.
180    pub revision: u64,
181    pub heikin_ashi: rendering::candles::HeikinAshiCache,
182    pub feed: FeedState,
183    pub markers: crate::widget::marks::TimeOrdered<Marker>,
184    pub marker_options: MarkerOptions,
185    pub change_marks: Option<crate::widget::change_marks::ChangeMarks>,
186    pub lines: Vec<(crate::widget::PriceLineId, PriceLine)>,
187}
188
189impl PriceSeries {
190    /// The prices of `bar` that the series draws, lowest first.
191    pub(in crate::widget) fn extremes(&self, bar: &Bar) -> [f64; 2] {
192        if self.options.chart_type.uses_ohlc() {
193            [bar.low, bar.high]
194        } else {
195            [bar.close; 2]
196        }
197    }
198
199    /// The last-value tag colour: the bar's own colour, or its direction for the OHLC types.
200    pub(in crate::widget) fn value_color(&self, bar: &Bar) -> Color32 {
201        if let Some(color) = bar.colors.as_ref().and_then(|own| own.color) {
202            return color;
203        }
204        match self.options.chart_type.uses_ohlc() {
205            true if bar.close >= bar.open => self.options.up_color,
206            true => self.options.down_color,
207            false => self.options.color,
208        }
209    }
210}
211
212/// A host series as a member of a pane below or above the price pane, which supplies the
213/// pane's legend row and its controls. The chart type renderer draws the bars, so the study
214/// holds no values to plot; its output reports the close at the crosshair, or the last close,
215/// at every bar.
216#[derive(Clone)]
217pub(in crate::widget) struct SeriesLegend {
218    name: String,
219    style: PlotStyle,
220    close: Option<f64>,
221    values: IndicatorValues,
222    formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
223    /// Decimals of the series' price scale, when it sets them.
224    precision: Option<usize>,
225    /// Groups the thousands of built-in prices, from the series' price scale.
226    thousands_sep: Option<char>,
227}
228
229impl SeriesLegend {
230    pub(in crate::widget) fn new(
231        item: &PriceSeries,
232        close: Option<f64>,
233        formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
234        precision: Option<usize>,
235        thousands_sep: Option<char>,
236    ) -> Self {
237        Self {
238            name: item.options.name.clone(),
239            style: PlotStyle {
240                color: item.options.color,
241                width: item.options.width,
242                pattern: LineStyle::Solid,
243                plot_type: PlotType::Line,
244                visible: true,
245            },
246            close,
247            formatter,
248            precision,
249            thousands_sep,
250            values: IndicatorValues::default(),
251        }
252    }
253}
254
255impl Indicator for SeriesLegend {
256    fn plot_style(&self, _line: usize) -> PlotStyle {
257        self.style
258    }
259
260    fn plot_value(&self, _line: usize, _bar: usize) -> Option<f64> {
261        self.close
262    }
263
264    fn format_value(&self, value: f64) -> String {
265        self.formatter.as_ref().map_or_else(
266            || {
267                crate::scales::group_thousands(
268                    StudyFormat::Price.format(value, self.precision),
269                    self.thousands_sep,
270                )
271            },
272            |formatter| formatter.format(value),
273        )
274    }
275
276    fn value_format(&self) -> StudyFormat {
277        StudyFormat::Price
278    }
279
280    fn name(&self) -> &str {
281        &self.name
282    }
283
284    fn calculate(&mut self, _data: &[Bar]) {}
285
286    fn values(&self) -> &IndicatorValues {
287        &self.values
288    }
289
290    fn colors(&self) -> Vec<Color32> {
291        vec![self.style.color]
292    }
293
294    fn set_colors(&mut self, _colors: Vec<Color32>) {}
295
296    fn line_names(&self) -> Vec<String> {
297        vec![self.name.clone()]
298    }
299
300    fn set_visible(&mut self, _visible: bool) {}
301
302    fn clone_box(&self) -> Box<dyn Indicator> {
303        Box::new(self.clone())
304    }
305}
306
307impl Chart {
308    /// Adds a bar series of any time-based chart type, with independent historical and
309    /// streaming bars, drawn above the main series in the price pane. Supply data through
310    /// [`set_data`](Self::set_data) and [`update`](Self::update); the host owns feeds. Times
311    /// are projected onto the main series' time axis, including its whitespace. Move the
312    /// series to another pane with [`move_series_to_pane`](Self::move_series_to_pane).
313    pub fn add_series(&mut self, options: SeriesOptions) -> SeriesId {
314        let options = options.sanitized();
315        let id = SeriesId(self.next_series);
316        self.next_series += 1;
317        self.ensure_series_scale(options.scale);
318        self.price_series.push(PriceSeries {
319            id,
320            options,
321            visible: true,
322            fed: false,
323            bars: Vec::new(),
324            revision: crate::model::next_revision(),
325            heikin_ashi: Default::default(),
326            feed: FeedState::default(),
327            markers: Default::default(),
328            marker_options: MarkerOptions::default(),
329            change_marks: None,
330            lines: Vec::new(),
331        });
332        self.host_events
333            .push(crate::widget::ChartEvent::SeriesAdded(id));
334        id
335    }
336
337    /// Replaces the display settings of an added bar series, including its chart type, style
338    /// and scale assignment, as [`add_series`](Self::add_series) accepts them. Returns `false`
339    /// for an unknown id. The main series is styled through
340    /// [`set_chart_type`](Self::set_chart_type),
341    /// [`set_chart_type_params`](Self::set_chart_type_params), the colours of
342    /// [`config_mut`](Self::config_mut) and [`series_style_mut`](Self::series_style_mut), and
343    /// assigned with [`set_series_scale`](Self::set_series_scale).
344    pub fn set_series_options(&mut self, id: SeriesId, options: SeriesOptions) -> bool {
345        let Some(at) = self.price_series.iter().position(|item| item.id == id) else {
346            return false;
347        };
348        let options = options.sanitized();
349        self.ensure_series_scale(options.scale);
350        let renamed = self.price_series[at].options.name != options.name;
351        self.price_series[at].options = options;
352        if renamed && self.is_fed(id) {
353            self.set_data(id, Vec::new());
354            self.fill_fed_series();
355        }
356        self.prune_series_scales();
357        true
358    }
359
360    /// Whether the chart's feed supplies the bars of a series, as for
361    /// [`add_feed_series`](Self::add_feed_series).
362    pub fn is_fed(&self, id: SeriesId) -> bool {
363        self.feed_link.is_some()
364            && self
365                .price_series
366                .iter()
367                .any(|item| item.id == id && item.fed)
368    }
369
370    /// The fed series that are still present, with the names they are fed for.
371    pub(in crate::widget) fn fed_series(&self) -> Vec<(SeriesId, String)> {
372        self.price_series
373            .iter()
374            .filter(|item| self.is_fed(item.id))
375            .map(|item| (item.id, item.options.name.clone()))
376            .collect()
377    }
378
379    /// The histories the feed supplies for the fed series, at the chart's interval.
380    pub(in crate::widget) fn fed_requests(&self) -> Vec<StudyDataRequest> {
381        let mut requests = Vec::new();
382        for (_, name) in self.fed_series() {
383            for symbol in fed_symbols(&name) {
384                let request = StudyDataRequest {
385                    symbol,
386                    timeframe: Some(self.timeframe().unwrap_or_default()),
387                    metric: None,
388                };
389                if !requests.contains(&request) {
390                    requests.push(request);
391                }
392            }
393        }
394        requests
395    }
396
397    /// Replaces the bars of the fed series with what the feed supplied for their symbols.
398    pub(in crate::widget) fn fill_fed_series(&mut self) {
399        let fed = self.fed_series();
400        if fed.is_empty() {
401            return;
402        }
403        let updates: Vec<_> = {
404            let data =
405                self.study_data
406                    .data(self.fed_requests(), self.symbol(), self.timeframe(), None);
407            let history = |symbol: &str| {
408                data.iter()
409                    .find(|data| data.request.symbol == symbol)
410                    .map(|data| data.bars)
411            };
412            fed.iter()
413                .map(|(id, name)| {
414                    let bars = match spread_of(name) {
415                        Some(spread) => spread.bars(history).unwrap_or_default(),
416                        None => fed_symbols(name)
417                            .first()
418                            .and_then(|symbol| history(symbol))
419                            .unwrap_or_default()
420                            .to_vec(),
421                    };
422                    (*id, bars)
423                })
424                .collect()
425        };
426        for (id, bars) in updates {
427            if self.bars(id) != bars.as_slice() {
428                self.set_data(id, bars);
429            }
430        }
431    }
432
433    /// The position of an added bar series in the draw order, which [`series`](Self::series)
434    /// follows. Each pane draws its added series in this order: they are drawn above the main
435    /// series, and a later one covers an earlier one.
436    pub fn series_order(&self, id: SeriesId) -> Option<usize> {
437        self.price_series.iter().position(|item| item.id == id)
438    }
439
440    /// Moves an added bar series to position `order` of the draw order, or to the end for a
441    /// larger value. Returns `false` for an unknown id.
442    pub fn set_series_order(&mut self, id: SeriesId, order: usize) -> bool {
443        let Some(at) = self.series_order(id) else {
444            return false;
445        };
446        let item = self.price_series.remove(at);
447        self.price_series
448            .insert(order.min(self.price_series.len()), item);
449        true
450    }
451
452    /// Adds a bar series like [`add_series`](Self::add_series) whose bars the chart's feed
453    /// supplies: the history and live updates of the symbol in `options.name`, at the chart's
454    /// interval, or of a spread such as `AAPL-MSFT` (see [`SymbolExpression`]), computed from
455    /// the history of each of its symbols. The series pages back as far as the main series, and
456    /// a new name is a new symbol. A chart without a feed adds a plain series, which the host
457    /// supplies through [`set_data`](Self::set_data); a symbol that contains an operator sign
458    /// cannot be named.
459    pub fn add_feed_series(&mut self, options: SeriesOptions) -> SeriesId {
460        let id = self.add_series(options);
461        self.price_series.last_mut().expect("added series").fed = self.feed_link.is_some();
462        self.fill_fed_series();
463        id
464    }
465
466    /// Adds a comparison line on the main series' scale and switches that scale to percent.
467    /// Each series uses its own first visible close as zero; scrolling recomputes references.
468    pub fn add_comparison(&mut self, name: impl Into<String>, color: Color32) -> SeriesId {
469        self.state.price_scale_mut().options_mut().mode = PriceScaleMode::Percentage;
470        self.state.price_scale_mut().reset();
471        self.add_series(SeriesOptions {
472            name: name.into(),
473            color,
474            scale: self.main_series_scale,
475            ..Default::default()
476        })
477    }
478
479    /// Added bar series and their display settings, in draw order.
480    pub fn series(&self) -> impl Iterator<Item = (SeriesId, &SeriesOptions)> {
481        self.price_series
482            .iter()
483            .map(|item| (item.id, &item.options))
484    }
485
486    /// Whether a bar series or study is present and visible.
487    pub fn is_series_visible(&self, id: SeriesId) -> bool {
488        if id == SeriesId::MAIN {
489            self.main_series_present && self.main_series_visible
490        } else if let Some(item) = self.price_series.iter().find(|item| item.id == id) {
491            item.visible
492        } else {
493            self.indicators()
494                .any(|(current, study)| current == id && study.is_visible())
495        }
496    }
497
498    /// Shows or hides a bar series or study without unloading its data.
499    /// Returns `false` when the id is absent. Hidden series do not contribute to auto-scaling.
500    pub fn set_series_visible(&mut self, id: SeriesId, visible: bool) -> bool {
501        if id == SeriesId::MAIN {
502            if !self.main_series_present {
503                return false;
504            }
505            self.set_main_series_visible(visible);
506        } else if let Some(item) = self.price_series.iter_mut().find(|item| item.id == id) {
507            item.visible = visible;
508            if !visible && self.selection.selected_id() == Some(ChartElementId::Series(id)) {
509                self.selection.deselect();
510            }
511        } else {
512            return self.set_indicator_visible(id, visible);
513        }
514        true
515    }
516
517    /// Assigns a bar series to the right, left, a hidden overlay or an additional labelled scale.
518    /// Shared assignments share options and manual ranges. Other series retain their assignments.
519    pub fn set_series_scale(&mut self, id: SeriesId, scale: SeriesScale) -> bool {
520        if id == SeriesId::MAIN && self.main_series_present {
521            if scale == self.main_series_scale {
522                return true;
523            }
524            self.ensure_series_scale(scale);
525            let next = self
526                .series_scales
527                .remove(&scale)
528                .expect("scale was inserted");
529            let old = std::mem::replace(self.state.price_scale_mut(), next);
530            // Joining a scale preserves its bounds in scale units, even with a new reference.
531            if self.state.price_scale().is_manual() {
532                let range = self.state.price_scale().range();
533                self.state.fit_price_scale(std::iter::empty());
534                self.state.price_scale_mut().set_range(range.min, range.max);
535            }
536            self.series_scales.insert(self.main_series_scale, old);
537            self.main_series_scale = scale;
538            self.config.show_left_axis = scale == SeriesScale::Left;
539            self.config.show_right_axis =
540                matches!(scale, SeriesScale::Right | SeriesScale::Separate(_));
541        } else if let Some(at) = self.price_series.iter().position(|item| item.id == id) {
542            self.ensure_series_scale(scale);
543            self.price_series[at].options.scale = scale;
544        } else {
545            return false;
546        }
547        self.prune_series_scales();
548        true
549    }
550
551    /// The scale assignment of a present bar series.
552    pub fn series_scale(&self, id: SeriesId) -> Option<SeriesScale> {
553        if id == SeriesId::MAIN && self.main_series_present {
554            Some(self.main_series_scale)
555        } else {
556            self.price_series
557                .iter()
558                .find(|item| item.id == id)
559                .map(|item| item.options.scale)
560        }
561    }
562
563    /// The numeric display format, last value label, price line, base line, autoscale and
564    /// click tolerance of a present bar series.
565    pub fn series_style(&self, id: SeriesId) -> Option<&SeriesStyle> {
566        if id == SeriesId::MAIN && self.main_series_present {
567            Some(&self.main_series_style)
568        } else {
569            self.price_series
570                .iter()
571                .find(|item| item.id == id)
572                .map(|item| &item.options.style)
573        }
574    }
575
576    /// Changes the numeric display format, last value label, price line, base line, autoscale
577    /// or click tolerance of a present bar series. Changes take effect on the next rendered frame.
578    pub fn series_style_mut(&mut self, id: SeriesId) -> Option<&mut SeriesStyle> {
579        if id == SeriesId::MAIN && self.main_series_present {
580            Some(&mut self.main_series_style)
581        } else {
582            self.price_series
583                .iter_mut()
584                .find(|item| item.id == id)
585                .map(|item| &mut item.options.style)
586        }
587    }
588
589    /// The scale shared by this present bar series, including its options and fitted range.
590    pub fn series_price_scale(&self, id: SeriesId) -> Option<&PriceScale> {
591        let scale = self.series_scale(id)?;
592        Some(
593            match self
594                .pane_layout
595                .panes
596                .iter()
597                .find(|pane| pane.members.contains(&id))
598            {
599                Some(pane) => &pane.scale,
600                None => self.secondary_scale(scale),
601            },
602        )
603    }
604
605    /// Changes options or a manual range on the scale of this bar series, as returned by
606    /// [`series_price_scale`](Self::series_price_scale).
607    /// Range bounds use scale units, such as percent in percentage mode.
608    pub fn series_price_scale_mut(&mut self, id: SeriesId) -> Option<&mut PriceScale> {
609        let scale = self.series_scale(id)?;
610        if let Some(at) = self
611            .pane_layout
612            .panes
613            .iter()
614            .position(|pane| pane.members.contains(&id))
615        {
616            Some(&mut self.pane_layout.panes[at].scale)
617        } else if scale == self.main_series_scale {
618            Some(self.state.price_scale_mut())
619        } else {
620            self.series_scales.get_mut(&scale)
621        }
622    }
623
624    /// The price mapping of a present bar series, the main one included, as last drawn in its
625    /// pane: [`PriceMap::price_to_y`] and [`PriceMap::y_to_price`] convert between its prices
626    /// and the y coordinates of the chart's [`Ui`](egui::Ui). `None` for an absent series, or
627    /// before its pane is first drawn.
628    pub fn series_price_map(&self, id: SeriesId) -> Option<PriceMap> {
629        if self.series_pane(id)? != PaneId::MAIN {
630            return self
631                .pane_layout
632                .panes
633                .iter()
634                .find(|pane| pane.members.contains(&id) && pane.rect.is_positive())
635                .map(|pane| {
636                    self.series_display_map(id, pane.price_map())
637                        .with_thousands_sep(self.config.thousands_sep)
638                });
639        }
640        let rect = self.last_rendered_price_rect;
641        if !rect.is_positive() {
642            None
643        } else if id == SeriesId::MAIN {
644            Some(self.last_price_map)
645        } else {
646            let item = self.price_series.iter().find(|item| item.id == id)?;
647            Some(self.series_map(item, rect))
648        }
649    }
650
651    /// Formats `price` as the scale of a present bar series labels it: a price, a signed
652    /// percentage or an indexed value. `None` where [`series_price_map`](Self::series_price_map)
653    /// is.
654    pub fn format_series_price(&self, id: SeriesId, price: f64) -> Option<String> {
655        let map = self.series_price_map(id)?;
656        let transform = map.transform();
657        Some(map.label(
658            transform.to_scale(price),
659            map.price_precision().unwrap_or(2),
660            self.series_label_formatter(id, transform.mode).as_deref(),
661        ))
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn baseline_marker_override_preserves_explicit_gradient_style() {
671        let style = BaselineStyle {
672            marker_radius: 4.0,
673            above_fill_at_base: Some(Color32::TRANSPARENT),
674            below_fill_at_base: Some(Color32::GOLD),
675            ..Default::default()
676        };
677        for radius in [None, Some(0.0), Some(7.0)] {
678            let options = SeriesOptions {
679                baseline_style: Some(style.clone()),
680                marker_radius: radius,
681                ..Default::default()
682            };
683            let mut expected = style.clone();
684            expected.marker_radius = radius.unwrap_or(style.marker_radius);
685            assert_eq!(options.type_params().styles.baseline, expected);
686            assert_eq!(
687                serde_json::from_value::<SeriesOptions>(serde_json::to_value(&options).unwrap())
688                    .unwrap(),
689                options
690            );
691        }
692    }
693
694    #[test]
695    fn added_baseline_series_keep_explicit_styles_and_legacy_colours() {
696        for gradient in [false, true] {
697            let mut saved = serde_json::to_value(SeriesOptions::default()).unwrap();
698            saved["up_color"] = serde_json::json!([0, 0, 255, 255]);
699            saved["down_color"] = serde_json::json!([255, 0, 0, 255]);
700            if gradient {
701                saved["baseline_style"] = serde_json::json!({
702                    "above_fill": [0, 200, 0, 200], "above_fill_at_base": [0, 40, 0, 40],
703                    "below_fill": [200, 0, 0, 200], "below_fill_at_base": [40, 0, 0, 40]
704                });
705            }
706            let options: SeriesOptions = serde_json::from_value(saved).unwrap();
707            let params = options.type_params();
708            if gradient {
709                let actual = serde_json::to_value(&params.styles.baseline).unwrap();
710                assert_eq!(actual["above_fill"], serde_json::json!([0, 200, 0, 200]));
711                assert_eq!(
712                    actual["above_fill_at_base"],
713                    serde_json::json!([0, 40, 0, 40])
714                );
715                assert_eq!(actual["below_fill"], serde_json::json!([200, 0, 0, 200]));
716                assert_eq!(
717                    actual["below_fill_at_base"],
718                    serde_json::json!([40, 0, 0, 40])
719                );
720            } else {
721                assert_eq!(params.styles.baseline.above.color, Color32::BLUE);
722                assert_eq!(params.styles.baseline.below.color, Color32::RED);
723                assert_eq!(
724                    params.styles.baseline.above_fill,
725                    Color32::from_rgba_unmultiplied(0, 0, 255, 30)
726                );
727                assert_eq!(
728                    params.styles.baseline.below_fill,
729                    Color32::from_rgba_unmultiplied(255, 0, 0, 30)
730                );
731            }
732            assert_eq!(
733                serde_json::from_value::<SeriesOptions>(serde_json::to_value(&options).unwrap())
734                    .unwrap(),
735                options
736            );
737        }
738    }
739}