Skip to main content

egui_charts/widget/
sync.rs

1//! Multi-chart crosshair and time-scale synchronization.
2
3use super::Chart;
4use chrono::{DateTime, Utc};
5
6// Keep a short received span readable even when manual zoom has no configured maximum.
7const SYNC_MAX_BAR_SPACING: f64 = 50.0;
8
9/// UTC timestamps at the left and right edges of a chart's plot.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct VisibleTimeRange {
12    /// Time at the leftmost plot pixel.
13    pub from: DateTime<Utc>,
14    /// Time at the rightmost plot pixel.
15    pub to: DateTime<Utc>,
16}
17
18impl Chart {
19    /// The time of the nearest hovered bar or price-based element, or `None` outside the data.
20    pub fn hover_time(&self) -> Option<DateTime<Utc>> {
21        self.hover_period().map(|(open, _)| open)
22    }
23
24    /// The hovered bar's period, from its open until the next bar of its interval opens.
25    pub(crate) fn hover_period(&self) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
26        let index = self.last_hover_bar_idx?.round();
27        (index >= 0.0 && index < self.state.shown().len() as f64)
28            .then(|| self.bar_period(index as usize))
29    }
30
31    // Until the next bar opens if that comes sooner, as after a repeated element time.
32    fn bar_period(&self, index: usize) -> (DateTime<Utc>, DateTime<Utc>) {
33        let bars = self.state.shown();
34        let open = bars[index].time;
35        // Without an interval, the first bar takes the spacing after the second.
36        let at = match self.timeframe() {
37            Some(_) => index,
38            None => index.max(1).min(bars.len() - 1),
39        };
40        // One slot past the bar as if it were the last, so a session gap after it is no interval.
41        let step = crate::model::time_at_with_timeframe(
42            &bars[..=at],
43            at as f64 + 1.0,
44            self.timeframe(),
45            self.calendar_timezone().as_ref(),
46        )
47        .map_or(chrono::TimeDelta::zero(), |end| end - bars[at].time);
48        let end = open.checked_add_signed(step).unwrap_or(open);
49        let end = bars.get(index + 1).map_or(end, |next| end.min(next.time));
50        (open, end.max(open))
51    }
52
53    /// Shows a crosshair at the local bar whose period holds `time`, from its open until the
54    /// next bar of its interval opens. Between bars it marks the nearest one; ties choose the
55    /// earlier bar. Times before the first bar or after the last bar's period, and `None`, clear
56    /// it. Repeated element times choose the first element. Each receiver resolves the timestamp
57    /// against its own series, including after bars are evicted or older history is loaded.
58    pub fn set_synced_crosshair_time(&mut self, time: Option<DateTime<Utc>>) {
59        self.set_synced_crosshair(time.map(|time| (time, time)), None);
60    }
61
62    /// Shows a crosshair at the local bar for `time`, as [`Self::set_synced_crosshair_time`]
63    /// does, with its horizontal line and price label at `price` on the main price scale.
64    /// `None`, a time outside the data or a non-finite price clears it. The pointer's own
65    /// crosshair takes its place while the pointer is over the plots.
66    pub fn set_crosshair_position(&mut self, position: Option<(DateTime<Utc>, f64)>) {
67        self.set_synced_crosshair(
68            position.map(|(time, _)| (time, time)),
69            position.map(|(_, price)| price),
70        );
71    }
72
73    /// As [`Self::set_crosshair_position`] for a source bar's `period`: when no local bar's
74    /// period holds its start, the first local bar opening inside it is marked, so a coarse bar
75    /// marks the first bar of its period on a finer chart closed at its open.
76    pub(crate) fn set_synced_crosshair(
77        &mut self,
78        period: Option<(DateTime<Utc>, DateTime<Utc>)>,
79        price: Option<f64>,
80    ) {
81        let period = period.filter(|_| price.is_none_or(f64::is_finite));
82        self.synced_crosshair_bar_idx = period.and_then(|(from, to)| self.synced_index(from, to));
83        self.synced_crosshair_period = period.filter(|_| self.synced_crosshair_bar_idx.is_some());
84        self.synced_crosshair_price = price.filter(|_| self.synced_crosshair_bar_idx.is_some());
85    }
86
87    fn synced_index(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Option<f64> {
88        let bars = self.state.shown();
89        let after = bars.partition_point(|bar| bar.time <= from);
90        let index = if after > 0 && from < self.bar_period(after - 1).1 {
91            after - 1
92        } else if after < bars.len() && bars[after].time < to {
93            after
94        } else if after == 0 || after == bars.len() {
95            return None;
96        } else if from - bars[after - 1].time <= bars[after].time - from {
97            after - 1
98        } else {
99            after
100        };
101        Some(bars.partition_point(|bar| bar.time < bars[index].time) as f64)
102    }
103
104    /// The visible UTC range, including whitespace mapped by [`Self::index_to_time`].
105    /// Empty charts return `None`.
106    /// Between bars, time is interpolated, so session gaps retain fractional edge positions.
107    pub fn visible_time_range(&self) -> Option<VisibleTimeRange> {
108        let scale = self.state.time_scale();
109        // Keep fractional indices in f64: a rounded edge can move minutes across a session gap.
110        Some(VisibleTimeRange {
111            from: self.index_to_time(scale.coord_to_idx_precise(0.0, 0.0, scale.width()))?,
112            to: self.index_to_time(scale.coord_to_idx_precise(
113                scale.width() - 1.0,
114                0.0,
115                scale.width(),
116            ))?,
117        })
118    }
119
120    /// Fits the receiver's bars to a UTC range using its own plot width.
121    /// Returns `false` without changing the view for empty data, reversed or zero ranges,
122    /// a range that ends before the first bar's slot or starts after the last bar's period,
123    /// or endpoints mapping to the same element.
124    /// Spacing stops at the configured maximum, or 50 pixels when unlimited, centring short
125    /// spans. The minimum spacing and local edge constraints still apply, and the fit keeps a
126    /// bar in view: a range longer than the plot at the minimum spacing shows the newest bars.
127    pub fn apply_synced_time_range(&mut self, range: VisibleTimeRange) -> bool {
128        self.apply_synced_time_range_from(range, None)
129    }
130
131    /// As [`apply_synced_time_range`](Self::apply_synced_time_range) for a range from a chart
132    /// with the interval and plot slots of [`Self::sync_source`]. A receiver of that interval
133    /// may exceed the 50-pixel cap up to the spacing at which its plot spans the source's slot
134    /// count, so charts with the same bar slots zoom together, while one with fewer slots in
135    /// the range, as across a closure the source trades through, stays readable.
136    pub(crate) fn apply_synced_time_range_from(
137        &mut self,
138        range: VisibleTimeRange,
139        source: Option<(crate::model::Timeframe, f64)>,
140    ) -> bool {
141        let cap = match source {
142            Some((timeframe, slots)) if Some(timeframe) == self.timeframe() => {
143                SYNC_MAX_BAR_SPACING.max((f64::from(self.state.time_scale().width()) - 1.0) / slots)
144            }
145            _ => SYNC_MAX_BAR_SPACING,
146        };
147        self.fit_time_range(range, cap)
148    }
149
150    /// The interval and the bar slots across the plot, `None` without an interval.
151    pub(crate) fn sync_source(&self) -> Option<(crate::model::Timeframe, f64)> {
152        let scale = self.state.time_scale();
153        let edge = |x| scale.coord_to_idx_precise(x, 0.0, scale.width());
154        Some((self.timeframe()?, edge(scale.width() - 1.0) - edge(0.0)))
155    }
156
157    /// As [`Self::apply_synced_time_range`] with `default_max` in place of the 50-pixel cap.
158    pub(super) fn fit_time_range(&mut self, range: VisibleTimeRange, default_max: f64) -> bool {
159        if range.from >= range.to {
160            return false;
161        }
162        let Some((from, to)) = self
163            .time_to_index(range.from)
164            .zip(self.time_to_index(range.to))
165        else {
166            return false;
167        };
168        let scale = self.state.time_scale_mut();
169        // The last bar's period runs to the next slot at `base_idx + 1`.
170        if to <= from || scale.width() <= 1.0 || to < -0.5 || from >= scale.base_idx() as f64 + 1.0
171        {
172            return false;
173        }
174        scale.fit_logical_range(from, to, default_max);
175        self.tracking_mode_active = false;
176        self.kinetic_scroll = super::state::KineticScrollState::new();
177        true
178    }
179
180    // =========================================================================
181    // Multi-Chart Sync Methods
182    // =========================================================================
183
184    /// Sets a crosshair in local index coordinates, counting elements on price-based types.
185    /// `None` clears it. Use [`Self::set_synced_crosshair_time`] between independent charts.
186    pub fn set_synced_crosshair_bar_idx(&mut self, bar_idx: Option<f64>) {
187        self.synced_crosshair_bar_idx = bar_idx;
188        self.synced_crosshair_period = None;
189        self.synced_crosshair_price = None;
190    }
191
192    /// Returns the nearest hovered local index from the last frame, counting elements on
193    /// price-based types. Use [`Self::hover_time`] to exchange positions between charts.
194    pub fn get_hover_bar_idx(&self) -> Option<f64> {
195        self.last_hover_bar_idx
196    }
197
198    /// Restores local bar spacing and right offset, for example after inspecting another view.
199    /// These coordinates depend on the data and plot width. Use [`Self::apply_synced_time_range`]
200    /// between independent charts.
201    pub fn apply_synced_time_scale(&mut self, bar_spacing: f32, right_offset: f32) {
202        self.state.time_scale_mut().set_bar_spacing(bar_spacing);
203        self.state.time_scale_mut().set_right_offset(right_offset);
204    }
205
206    /// Local time-scale state as `(bar_spacing, right_offset)`, for view restoration.
207    /// Use [`Self::visible_time_range`] to exchange viewports between independent charts.
208    pub fn get_time_scale_state(&self) -> (f32, f32) {
209        (
210            self.state.time_scale().bar_spacing(),
211            self.state.time_scale().right_offset(),
212        )
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::model::{Bar, BarData, index_at, time_at};
220    use chrono::Duration;
221
222    fn chart(times: &[i64], width: f32) -> Chart {
223        let bars = times
224            .iter()
225            .map(|&t| Bar::new(time(t), 10.0, 11.0, 9.0, 10.0, 1.0))
226            .collect();
227        let mut chart = Chart::new(BarData::new());
228        // Shown price-based elements can repeat a timestamp, unlike cleaned input bars.
229        chart.state.set_data(BarData::from_bars(bars));
230        chart.state.time_scale_mut().set_width(width);
231        chart
232    }
233
234    #[test]
235    fn synced_crosshair_keeps_its_time_when_history_changes() {
236        use crate::widget::test_frames::frame;
237        use crate::widget::{ImageExportOptions, SeriesId};
238
239        let bar = |minute: i64| Bar::new(time(minute * 60), 10.0, 11.0, 9.0, 10.0, 1.0);
240        for price in [Some(10.5), None] {
241            for (prepend, minute, expected_index) in [
242                (false, 10, Some(7.0)),
243                (true, 10, Some(14.0)),
244                (false, 1, None),
245            ] {
246                let mut chart = Chart::new(BarData::from_bars((0..16).map(bar).collect()));
247                if !prepend {
248                    chart.set_bar_limit(SeriesId::MAIN, std::num::NonZeroUsize::new(16));
249                }
250                let ctx = egui::Context::default();
251                frame(&ctx, &mut chart, 0.0, Vec::new());
252                let selected = time(minute * 60);
253                match price {
254                    Some(price) => chart.set_crosshair_position(Some((selected, price))),
255                    None => chart.set_synced_crosshair_time(Some(selected)),
256                }
257                frame(&ctx, &mut chart, 0.1, Vec::new());
258                assert_eq!(chart.last_crosshair, Some((selected, price)));
259                if prepend {
260                    for minute in -4..0 {
261                        chart.update(SeriesId::MAIN, bar(minute));
262                    }
263                    assert_eq!(chart.bars(SeriesId::MAIN)[0].time, time(-240));
264                } else {
265                    chart.update(SeriesId::MAIN, bar(16));
266                    assert_eq!(chart.bars(SeriesId::MAIN)[0].time, time(180));
267                }
268                let expected = expected_index.map(|_| (selected, price));
269                frame(&ctx, &mut chart, 0.2, Vec::new());
270                assert_eq!(chart.synced_crosshair_bar_idx, expected_index);
271                assert_eq!(chart.last_crosshair, expected);
272                let options = ImageExportOptions {
273                    include_crosshair: true,
274                    ..Default::default()
275                };
276                chart
277                    .export_layout_image(&ctx, &options, |ui, snapshot| {
278                        snapshot.show(ui);
279                        assert_eq!(snapshot.last_crosshair, expected);
280                    })
281                    .unwrap()
282                    .textures_delta
283                    .clear();
284                // An explicit index replaces the time anchor and keeps its local semantics.
285                chart.set_synced_crosshair_bar_idx(Some(5.0));
286                chart.update(SeriesId::MAIN, bar(-5));
287                frame(&ctx, &mut chart, 0.3, Vec::new());
288                assert_eq!(chart.synced_crosshair_bar_idx, Some(5.0));
289                assert_eq!(
290                    chart.last_crosshair,
291                    Some((chart.state.shown()[5].time, None))
292                );
293            }
294        }
295    }
296
297    fn time(seconds: i64) -> DateTime<Utc> {
298        DateTime::from_timestamp(seconds, 0).unwrap()
299    }
300
301    #[test]
302    fn sync_keeps_coarse_receivers_readable() {
303        let hourly: Vec<_> = (0..24).map(|i| i * 3600).collect();
304        let daily: Vec<_> = (0..30).map(|i| i * 86_400).collect();
305        let minutes: Vec<_> = (0..1000).map(|i| i * 60).collect();
306        // A 65.5-hour closure before the last minute.
307        let mut closed = minutes.clone();
308        closed.push(999 * 60 + 235_800);
309        // Minutes from the last bar's open, and the time at the last plot pixel. A receiver
310        // without an interval takes a range as from a source without one; given the slots of a
311        // one-minute source, the receiver is a one-minute chart.
312        for (times, slots, from, to, maximum, expected, right) in [
313            (&hourly, None, 70, 170, 0.0, None, None),
314            (&hourly, None, -1550, -1450, 0.0, None, None),
315            // Centred on index 23.5, 50 px per slot shows five slots each side.
316            (&hourly, None, 10, 50, 0.0, Some(50.0), Some(330.0)),
317            (&hourly, None, 10, 50, 25.0, Some(25.0), Some(630.0)),
318            (&hourly, None, 10, 50, 100.0, Some(100.0), Some(180.0)),
319            (&hourly, None, -121_380, 0, 0.0, Some(0.5), Some(0.0)),
320            // The second half of the last bar's period.
321            (&hourly, None, 35, 55, 0.0, Some(50.0), Some(345.0)),
322            (&daily, None, 1050, 1170, 0.0, Some(50.0), Some(8310.0)),
323            // Half a slot across the plot keeps the last bar at its centre.
324            (&hourly, None, 35, 55, 1000.0, Some(1000.0), Some(15.0)),
325            // 90 days ending three days after the last minute show the newest minutes.
326            (&minutes, None, -125_280, 4320, 0.0, Some(0.5), Some(0.5)),
327            // Four hours of the closure are 0.06 of a slot here, but 240 at the source.
328            (&closed, Some(240.0), -3330, -3090, 0.0, Some(50.0), None),
329        ] {
330            let last_open = *times.last().unwrap();
331            let (from, to) = (last_open + from * 60, last_open + to * 60);
332            let mut receiver = chart(times, 501.0);
333            let source = slots.map(|slots| (crate::model::Timeframe::Min1, slots));
334            receiver.timeframe = source.map(|(timeframe, _)| timeframe);
335            receiver.state.time_scale_mut().set_max_bar_spacing(maximum);
336            receiver.tracking_mode_active = true;
337            let before = receiver.get_time_scale_state();
338            assert_eq!(
339                receiver.apply_synced_time_range_from(
340                    VisibleTimeRange {
341                        from: time(from),
342                        to: time(to),
343                    },
344                    source,
345                ),
346                expected.is_some(),
347                "{from}..{to}, max {maximum}"
348            );
349            let Some(spacing) = expected else {
350                assert_eq!(receiver.get_time_scale_state(), before);
351                assert!(receiver.tracking_mode_active);
352                continue;
353            };
354            let scale = receiver.state.time_scale();
355            assert!((scale.bar_spacing() - spacing).abs() < 1e-4, "{from}..{to}");
356            let edge = |x| scale.coord_to_idx_precise(x, 0.0, 501.0);
357            let (left, last) = (edge(0.0), (times.len() - 1) as f64);
358            assert!(
359                left.ceil().max(0.0) <= edge(500.0).floor().min(last),
360                "{from}..{to}"
361            );
362            assert!(!receiver.tracking_mode_active);
363            if let Some(right) = right {
364                let actual = receiver.visible_time_range().unwrap().to;
365                let expected = time(last_open + (right * 60.0) as i64);
366                assert!(
367                    (actual - expected).num_milliseconds().abs() < 1,
368                    "{from}..{to}"
369                );
370            }
371        }
372    }
373
374    #[test]
375    fn sync_resolves_receiver_times_and_fractional_edges() {
376        const H: i64 = 3600;
377        const D: i64 = 86_400;
378        let minutes = vec![0, 60, 120, 180, 240, 300];
379        let (gap, doubled) = (vec![60, 120, 240, 300], vec![60, 120, 120, 300]);
380        let days: Vec<_> = (0..30).map(|i| i * D).collect();
381        let four_hours: Vec<_> = (0..6).map(|i| i * 4 * H).collect();
382        // Sessions from 13:30 to 19:59 on days 0 and 1.
383        let sessions: Vec<_> = (0..2)
384            .flat_map(|day| (0..390).map(move |i| day * D + 13 * H + 1800 + i * 60))
385            .collect();
386        // A source of one bar has a period of one second.
387        let one = |time| vec![time];
388        // 120 seconds is source bar 2, receiver bar 1; 180 is halfway across its session gap.
389        for (source, hovered, times, expected) in [
390            (&minutes, Some(2.0), &gap, Some(1.0)),
391            (&minutes, Some(3.0), &gap, Some(1.0)),
392            (&minutes, Some(2.0), &doubled, Some(1.0)),
393            (&minutes, Some(3.0), &doubled, Some(1.0)),
394            (&minutes, Some(0.0), &gap, None),
395            (&minutes, Some(6.0), &gap, None),
396            (&minutes, Some(2.0), &Vec::new(), None),
397            (&minutes, None, &one(120), None),
398            // A time inside a coarser bar's period marks that bar, the latest one included.
399            (&one(10 * D + 14 * H + 1800), Some(0.0), &days, Some(10.0)),
400            (&one(29 * D + 14 * H + 1800), Some(0.0), &days, Some(29.0)),
401            (&one(30 * D + 1800), Some(0.0), &days, None),
402            (&one(3 * H), Some(0.0), &four_hours, Some(0.0)),
403            // The daily bar of day 1 marks its first minute, not the last minute of day 0.
404            (&days, Some(1.0), &sessions, Some(390.0)),
405        ] {
406            let mut source = chart(source, 1000.0);
407            source.last_hover_bar_idx = hovered;
408            let mut receiver = chart(times, 501.0);
409            receiver.set_synced_crosshair(source.hover_period(), None);
410            assert_eq!(
411                receiver.synced_crosshair_bar_idx, expected,
412                "{times:?} {hovered:?}"
413            );
414        }
415        let repeated = chart(&[0, 60, 60, 120], 501.0);
416        for (index, seconds) in [(0.5, 30), (1.5, 60), (2.5, 90), (3.5, 150)] {
417            assert_eq!(time_at(repeated.state.shown(), index), Some(time(seconds)));
418        }
419        // Repeated trailing times still extend the same last segment in both directions:
420        // index 3.5 is 60 + (3.5 - 2) * (60 / 2) = 105 seconds.
421        let trailing = chart(&[0, 60, 60], 501.0);
422        assert_eq!(time_at(trailing.state.shown(), 3.5), Some(time(105)));
423        assert_eq!(index_at(trailing.state.shown(), time(105)), Some(3.5));
424        #[cfg(feature = "drawings")]
425        {
426            let mut repeated = repeated;
427            let mut drawing =
428                crate::drawings::Drawing::new(7, crate::drawings::DrawingToolType::TrendLine);
429            drawing
430                .chart_points
431                .push(crate::drawings::ChartPoint::new(1.5, 10.0));
432            repeated.drawings.drawings.push(drawing);
433            repeated.anchor_drawing_points();
434            repeated.project_drawing_points();
435            assert_eq!(repeated.drawings.drawings[0].chart_points[0].bar_idx, 1.5);
436        }
437        let mut source = chart(&[0, 60, 120, 180, 240, 300], 1001.0);
438        // Pixel edges at source indices 0.5 and 4.5 represent 30 and 270 seconds.
439        source.apply_synced_time_scale(250.0, -1.0);
440        let range = source.visible_time_range().unwrap();
441        assert_eq!(
442            range,
443            VisibleTimeRange {
444                from: time(30),
445                to: time(270)
446            }
447        );
448        for (times, expected_spacing, expected_offset) in [
449            (vec![60, 120, 240, 300], 500.0 / 3.0, -1.0),
450            (vec![0, 120, 240, 360], 250.0, -1.25),
451        ] {
452            let mut receiver = chart(&times, 501.0);
453            receiver.state.time_scale_mut().set_max_bar_spacing(5000.0);
454            assert!(receiver.apply_synced_time_range(range));
455            let (spacing, offset) = receiver.get_time_scale_state();
456            assert!((spacing - expected_spacing).abs() < 0.001);
457            assert!((offset - expected_offset).abs() < 0.001);
458            let actual = receiver.visible_time_range().unwrap();
459            assert!((actual.from - range.from).num_milliseconds().abs() < 1);
460            assert!((actual.to - range.to).num_milliseconds().abs() < 1);
461            let before = receiver.get_time_scale_state();
462            assert!(!receiver.apply_synced_time_range(VisibleTimeRange {
463                from: range.to,
464                to: range.from
465            }));
466            assert_eq!(receiver.get_time_scale_state(), before);
467        }
468        // A 65.5-hour closure: the requested edges are 8 and 32 hours (less one second)
469        // after its first bar, at fractions 28_800/235_800 and 115_199/235_800.
470        let mut times: Vec<i64> = (0..=4096).map(|index| index * 60).collect();
471        let start = *times.last().unwrap();
472        times.extend((0..1024).map(|index| start + 235_800 + index * 60));
473        let range = VisibleTimeRange {
474            from: time(start + 28_800),
475            to: time(start + 115_199),
476        };
477        for width in [251.0, 592.125] {
478            let mut receiver = chart(&times, width);
479            receiver.state.time_scale_mut().set_max_bar_spacing(5000.0);
480            assert!(receiver.apply_synced_time_range(range));
481            let actual = receiver.visible_time_range().unwrap();
482            assert!((actual.from - range.from).num_milliseconds().abs() < 1);
483            assert!((actual.to - range.to).num_milliseconds().abs() < 1);
484        }
485        // A day inside a three-day session gap spans one third of a bar, even after long history.
486        for count in [2, 20_000] {
487            let mut times: Vec<_> = (0..count).map(|i| i * 60).collect();
488            let last = times[count as usize - 1];
489            times.push(last + 3 * 86_400);
490            let mut receiver = chart(&times, 1001.0);
491            receiver.state.time_scale_mut().set_max_bar_spacing(5000.0);
492            let range = VisibleTimeRange {
493                from: time(last + 86_400),
494                to: time(last + 2 * 86_400),
495            };
496            assert!(receiver.apply_synced_time_range(range));
497            let actual = receiver.visible_time_range().unwrap();
498            assert!((actual.from - range.from).num_milliseconds().abs() < 10);
499            assert!((actual.to - range.to).num_milliseconds().abs() < 10);
500        }
501        assert!(!chart(&[], 501.0).apply_synced_time_range(range));
502        // Execution bars need sub-millisecond precision; no epoch-sized float conversion.
503        let mut fine = chart(&[0, 1], 501.0);
504        fine.state.set_data(BarData::from_bars(
505            [0, 100, 200]
506                .map(|ns| {
507                    Bar::new(
508                        time(1_700_000_000) + Duration::nanoseconds(ns),
509                        10.0,
510                        11.0,
511                        9.0,
512                        10.0,
513                        1.0,
514                    )
515                })
516                .to_vec(),
517        ));
518        let t = time(1_700_000_000) + Duration::nanoseconds(150);
519        assert_eq!(time_at(fine.state.shown(), 1.5), Some(t));
520        assert_eq!(index_at(fine.state.shown(), t), Some(1.5));
521    }
522}