livesplit_core/component/
graph.rs

1//! Provides the Graph Component and relevant types for using it. The Graph
2//! Component visualizes how far the current attempt has been ahead or behind
3//! the chosen comparison throughout the whole attempt. Every point of the graph
4//! represents a split. Its x-coordinate is proportional to the split time and
5//! its y-coordinate is proportional to the split delta. The entire diagram is
6//! refered to as the chart and it contains the graph. The x-axis is the
7//! horizontal line that separates positive deltas from negative ones.
8
9// The words "padding" and "content" are from the CSS box model. "Padding" is an
10// area at the top/bottom that stays empty so that the graph doesn't touch the
11// edge. "Content" is the rest (the area inside).
12
13use crate::{
14    analysis, comparison,
15    platform::prelude::*,
16    settings::{Color, Field, SettingsDescription, Value},
17    timing::Snapshot,
18    GeneralLayoutSettings, TimeSpan, Timer, TimerPhase,
19};
20use alloc::borrow::Cow;
21use serde::{Deserialize, Serialize};
22
23const WIDTH: f32 = 1.0;
24const HEIGHT: f32 = 1.0;
25const DEFAULT_X_AXIS: f32 = HEIGHT / 2.0;
26
27/// The Graph Component visualizes how far the current attempt has been ahead or
28/// behind the chosen comparison throughout the whole attempt. All the
29/// individual deltas are shown as points in a graph.
30#[derive(Default, Clone)]
31pub struct Component {
32    settings: Settings,
33}
34
35/// The Settings for this component.
36#[derive(Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct Settings {
39    /// The comparison chosen. Uses the Timer's current comparison if set to
40    /// `None`.
41    pub comparison_override: Option<String>,
42    /// Specifies if the best segments should be colored with the layout's best
43    /// segment color.
44    pub show_best_segments: bool,
45    /// Specifies if the graph should automatically adjust to all changes. If
46    /// this is deactivated, changes to the graph only happen whenever the
47    /// current segment changes.
48    pub live_graph: bool,
49    /// Flips the chart. If set to `false`, split times which are ahead of the
50    /// comparison are displayed below the x-axis and times which are behind are
51    /// above it. Enabling this settings flips it.
52    pub flip_graph: bool,
53    /// The background color for the chart region containing the times that are
54    /// behind the comparison.
55    pub behind_background_color: Color,
56    /// The background color for the chart region containing the times that are
57    /// ahead of the comparison.
58    pub ahead_background_color: Color,
59    /// The color of the chart's grid lines.
60    pub grid_lines_color: Color,
61    /// The color of the lines connecting the graph's points.
62    pub graph_lines_color: Color,
63    /// The color of the region enclosed by the x-axis and the graph. The
64    /// partial fill color is only used for live changes. More specifically,
65    /// this color is used in the interval from the last split time to the
66    /// current time.
67    pub partial_fill_color: Color,
68    /// The color of the region enclosed by the x-axis and the graph, excluding
69    /// the graph segment with live changes.
70    pub complete_fill_color: Color,
71    /// The height of the chart.
72    pub height: u32,
73}
74
75/// The state object describes the information to visualize for this component.
76/// All coordinates are in the range `0..1`.
77#[derive(Default, Serialize, Deserialize)]
78pub struct State {
79    /// All of the graph's points. Connect them to visualize the graph.
80    /// If the live delta is active, the last point is to be interpreted as a
81    /// preview of the next split. Use the partial fill color to visualize the
82    /// region beneath that graph segment.
83    pub points: Vec<Point>,
84    /// The y-coordinates of all the horizontal grid lines.
85    pub horizontal_grid_lines: Vec<f32>,
86    /// The x-coordinates of all the vertical grid lines.
87    pub vertical_grid_lines: Vec<f32>,
88    /// The y-coordinate of the x-axis.
89    pub middle: f32,
90    /// If the live delta is active, the last point is to be interpreted as a
91    /// preview of the next split. Use the partial fill color to visualize the
92    /// region beneath that graph segment.
93    pub is_live_delta_active: bool,
94    /// Describes whether the chart is flipped vertically. For visualization,
95    /// this can usually be ignored, as it is already regarded in the
96    /// other variables.
97    pub is_flipped: bool,
98    /// The background color of the region of the chart that is above the
99    /// x-axis.
100    pub top_background_color: Color,
101    /// The background color of the region of the chart that is below the
102    /// x-axis.
103    pub bottom_background_color: Color,
104    /// The color of the chart's grid lines.
105    pub grid_lines_color: Color,
106    /// The color of the lines connecting the graph's points.
107    pub graph_lines_color: Color,
108    /// The color of the region enclosed by the x-axis and the graph. The
109    /// partial fill color is only used for live changes. More specifically,
110    /// this color is used in the interval from the last split time to the
111    /// current time.
112    pub partial_fill_color: Color,
113    /// The color of the region enclosed by the x-axis and the graph, excluding
114    /// the graph segment with live changes.
115    pub complete_fill_color: Color,
116    /// The color of the lines of graph segments that achieved a new best
117    /// segment time.
118    pub best_segment_color: Color,
119    /// The height of the chart.
120    pub height: u32,
121}
122
123/// Describes a point on the graph to visualize.
124#[derive(Serialize, Deserialize)]
125pub struct Point {
126    /// The x-coordinate of the point.
127    pub x: f32,
128    /// The y-coordinate of the point.
129    // N.B. this is initially set to an intermediate value which needs to be
130    // transformed before being sent to the renderer, see transform_y_coordinates.
131    pub y: f32,
132    /// Describes whether the segment this point is visualizing achieved a new
133    /// best segment time. Use the best segment color for it, in that case.
134    pub is_best_segment: bool,
135}
136
137impl Default for Settings {
138    fn default() -> Self {
139        Self {
140            comparison_override: None,
141            show_best_segments: false,
142            live_graph: true,
143            flip_graph: false,
144            behind_background_color: Color::rgba(115.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0, 1.0),
145            ahead_background_color: Color::rgba(40.0 / 255.0, 115.0 / 255.0, 52.0 / 255.0, 1.0),
146            grid_lines_color: Color::rgba(0.0, 0.0, 0.0, 0.15),
147            graph_lines_color: Color::rgba(1.0, 1.0, 1.0, 1.0),
148            partial_fill_color: Color::rgba(1.0, 1.0, 1.0, 0.25),
149            complete_fill_color: Color::rgba(1.0, 1.0, 1.0, 0.4),
150            height: 80,
151        }
152    }
153}
154
155#[cfg(feature = "std")]
156impl State {
157    /// Encodes the state object's information as JSON.
158    pub fn write_json<W>(&self, writer: W) -> serde_json::Result<()>
159    where
160        W: std::io::Write,
161    {
162        serde_json::to_writer(writer, self)
163    }
164}
165
166/// Private struct to reduce the number of function arguments.
167#[derive(Default)]
168struct DrawInfo {
169    points: Vec<Point>,
170    /// The lowest delta value in seconds.
171    min_delta: f32,
172    /// The highest delta value in seconds.
173    max_delta: f32,
174    scale_factor_x: Option<f32>,
175    scale_factor_y: Option<f32>,
176    padding_y: f32,
177    split_index: usize,
178    flip_graph: bool,
179    is_live_delta_active: bool,
180}
181
182#[derive(Default)]
183struct GridLines {
184    /// The offset of the first grid line followed by the grid line distance.
185    horizontal: Option<(f32, f32)>,
186    vertical: Option<f32>,
187}
188
189impl Component {
190    /// Creates a new Graph Component.
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Creates a new Graph Component with the given settings.
196    pub const fn with_settings(settings: Settings) -> Self {
197        Self { settings }
198    }
199
200    /// Accesses the settings of the component.
201    pub const fn settings(&self) -> &Settings {
202        &self.settings
203    }
204
205    /// Grants mutable access to the settings of the component.
206    pub fn settings_mut(&mut self) -> &mut Settings {
207        &mut self.settings
208    }
209
210    /// Accesses the name of the component.
211    pub fn name(&self) -> Cow<'static, str> {
212        self.text(
213            self.settings
214                .comparison_override
215                .as_ref()
216                .map(String::as_ref),
217        )
218    }
219
220    fn text(&self, comparison: Option<&str>) -> Cow<'static, str> {
221        if let Some(comparison) = comparison {
222            format!("Graph ({})", comparison::shorten(comparison)).into()
223        } else {
224            "Graph".into()
225        }
226    }
227
228    /// Updates the component's state based on the timer and layout settings
229    /// provided.
230    pub fn update_state(
231        &self,
232        state: &mut State,
233        timer: &Snapshot<'_>,
234        layout_settings: &GeneralLayoutSettings,
235    ) {
236        let mut draw_info = DrawInfo {
237            flip_graph: self.settings.flip_graph,
238            ..DrawInfo::default()
239        };
240
241        let x_axis = self
242            .calculate_graph(timer, &mut draw_info)
243            .unwrap_or(DEFAULT_X_AXIS);
244
245        if draw_info.points.is_empty() {
246            draw_info.points.push(Point {
247                x: 0.0,
248                y: DEFAULT_X_AXIS,
249                is_best_segment: false,
250            });
251        }
252
253        let grid_lines = calculate_grid_lines(&draw_info, x_axis);
254        update_grid_line_vecs(state, grid_lines);
255        self.copy_settings_to_state(state);
256        state.best_segment_color = layout_settings.best_segment_color;
257        state.middle = x_axis;
258        state.is_live_delta_active = draw_info.is_live_delta_active;
259        state.points = draw_info.points;
260    }
261
262    /// Calculates the component's state based on the timer and layout settings
263    /// provided.
264    pub fn state(&self, timer: &Snapshot<'_>, layout_settings: &GeneralLayoutSettings) -> State {
265        let mut state = State::default();
266        self.update_state(&mut state, timer, layout_settings);
267        state
268    }
269
270    /// Accesses a generic description of the settings available for this
271    /// component and their current values.
272    pub fn settings_description(&self) -> SettingsDescription {
273        SettingsDescription::with_fields(vec![
274            Field::new(
275                "Comparison".into(),
276                self.settings.comparison_override.clone().into(),
277            ),
278            Field::new("Height".into(), u64::from(self.settings.height).into()),
279            Field::new(
280                "Show Best Segments".into(),
281                self.settings.show_best_segments.into(),
282            ),
283            Field::new("Live Graph".into(), self.settings.live_graph.into()),
284            Field::new("Flip Graph".into(), self.settings.flip_graph.into()),
285            Field::new(
286                "Behind Background Color".into(),
287                self.settings.behind_background_color.into(),
288            ),
289            Field::new(
290                "Ahead Background Color".into(),
291                self.settings.ahead_background_color.into(),
292            ),
293            Field::new(
294                "Grid Lines Color".into(),
295                self.settings.grid_lines_color.into(),
296            ),
297            Field::new(
298                "Graph Lines Color".into(),
299                self.settings.graph_lines_color.into(),
300            ),
301            Field::new(
302                "Partial Fill Color".into(),
303                self.settings.partial_fill_color.into(),
304            ),
305            Field::new(
306                "Complete Fill Color".into(),
307                self.settings.complete_fill_color.into(),
308            ),
309        ])
310    }
311
312    /// Sets a setting's value by its index to the given value.
313    ///
314    /// # Panics
315    ///
316    /// This panics if the type of the value to be set is not compatible with
317    /// the type of the setting's value. A panic can also occur if the index of
318    /// the setting provided is out of bounds.
319    pub fn set_value(&mut self, index: usize, value: Value) {
320        match index {
321            0 => self.settings.comparison_override = value.into(),
322            1 => self.settings.height = value.into_uint().unwrap() as _,
323            2 => self.settings.show_best_segments = value.into(),
324            3 => self.settings.live_graph = value.into(),
325            4 => self.settings.flip_graph = value.into(),
326            5 => self.settings.behind_background_color = value.into(),
327            6 => self.settings.ahead_background_color = value.into(),
328            7 => self.settings.grid_lines_color = value.into(),
329            8 => self.settings.graph_lines_color = value.into(),
330            9 => self.settings.partial_fill_color = value.into(),
331            10 => self.settings.complete_fill_color = value.into(),
332            _ => panic!("Unsupported Setting Index"),
333        }
334    }
335
336    fn calculate_graph(&self, timer: &Snapshot<'_>, draw_info: &mut DrawInfo) -> Option<f32> {
337        let settings = &self.settings;
338        draw_info.split_index = timer.current_split_index()?;
339        let comparison = comparison::resolve(&self.settings.comparison_override, timer);
340        let comparison = comparison::or_current(comparison, timer);
341
342        calculate_horizontal_scaling(timer, draw_info, settings.live_graph);
343        draw_info.scale_factor_x?;
344
345        draw_info.points = Vec::with_capacity(draw_info.split_index + 1);
346        draw_info.points.push(Point {
347            x: 0.0,
348            y: 0.0, // Not the final value of y, this will end up on the x-axis.
349            is_best_segment: false,
350        });
351
352        calculate_split_points(timer, draw_info, comparison, settings.show_best_segments);
353        if settings.live_graph {
354            calculate_live_delta_point(timer, draw_info, comparison);
355        }
356
357        calculate_vertical_scaling(draw_info);
358        let x_axis = calculate_x_axis(draw_info);
359
360        transform_y_coordinates(draw_info);
361
362        Some(x_axis)
363    }
364
365    fn copy_settings_to_state(&self, state: &mut State) {
366        let settings = &self.settings;
367        (state.top_background_color, state.bottom_background_color) = if settings.flip_graph {
368            (
369                settings.ahead_background_color,
370                settings.behind_background_color,
371            )
372        } else {
373            (
374                settings.behind_background_color,
375                settings.ahead_background_color,
376            )
377        };
378
379        state.is_flipped = settings.flip_graph;
380        state.grid_lines_color = settings.grid_lines_color;
381        state.graph_lines_color = settings.graph_lines_color;
382        state.partial_fill_color = settings.partial_fill_color;
383        state.complete_fill_color = settings.complete_fill_color;
384        state.height = settings.height;
385    }
386}
387
388fn calculate_horizontal_scaling(timer: &Snapshot<'_>, draw_info: &mut DrawInfo, live_graph: bool) {
389    let timing_method = timer.current_timing_method();
390
391    // final_split is the split time of a theoretical point on the right edge of
392    // the chart.
393    let mut final_split = 0.0;
394    if live_graph {
395        let current_time = timer.current_time();
396        final_split = current_time[timing_method]
397            .or(current_time.real_time)
398            .unwrap_or_else(TimeSpan::zero)
399            .total_seconds() as f32;
400    } else {
401        // Find the last segment with a split time.
402        for segment in timer.run().segments()[..draw_info.split_index].iter().rev() {
403            if let Some(time) = segment.split_time()[timing_method] {
404                final_split = time.total_seconds() as f32;
405                break;
406            }
407        }
408    }
409
410    if final_split > 0.0 {
411        draw_info.scale_factor_x = Some(WIDTH / final_split);
412    }
413
414    // Else scaling doesn't matter and scale_factor_x stays None.
415}
416
417/// Calculates the points' x-coordinates and their deltas, which determine their
418/// y-coordinates. The deltas are stored as the points' y-coordinates and will
419/// have to be corrected before rendering.
420fn calculate_split_points(
421    timer: &Timer,
422    draw_info: &mut DrawInfo,
423    comparison: &str,
424    show_best_segments: bool,
425) {
426    let timing_method = timer.current_timing_method();
427
428    for (i, segment) in timer.run().segments()[..draw_info.split_index]
429        .iter()
430        .enumerate()
431    {
432        catch! {
433            let split_time = segment.split_time()[timing_method]?;
434            let comparison_time = segment.comparison(comparison)[timing_method]?;
435            let delta = (split_time - comparison_time).total_seconds() as f32;
436
437            if delta > draw_info.max_delta {
438                draw_info.max_delta = delta;
439            } else if delta < draw_info.min_delta {
440                draw_info.min_delta = delta;
441            }
442
443            let x = split_time.total_seconds() as f32 * draw_info.scale_factor_x.unwrap_or(0.0);
444
445            let is_best_segment =
446                show_best_segments && analysis::check_best_segment(timer, i, timing_method);
447
448            draw_info.points.push(Point {
449                x,
450                y: delta, // Not the final value of y.
451                is_best_segment,
452            });
453        };
454    }
455}
456
457fn calculate_live_delta_point(timer: &Snapshot<'_>, draw_info: &mut DrawInfo, comparison: &str) {
458    if timer.current_phase() == TimerPhase::Ended {
459        return;
460    }
461
462    let timing_method = timer.current_timing_method();
463    let mut live_delta = analysis::check_live_delta(timer, true, comparison, timing_method);
464    let current_time = timer.current_time()[timing_method];
465    let current_split_comparison = timer
466        .run()
467        .segment(draw_info.split_index)
468        .comparison(comparison)[timing_method];
469
470    if let (Some(current_time), Some(current_split_comparison), None) =
471        (current_time, current_split_comparison, live_delta)
472    {
473        // Live delta should be shown despite what analysis::check_live_delta says.
474        let delta = current_time - current_split_comparison;
475        if delta.total_seconds() as f32 > draw_info.min_delta {
476            live_delta = Some(delta);
477        }
478    }
479
480    if let Some(live_delta) = live_delta {
481        let delta = live_delta.total_seconds() as f32;
482        if delta > draw_info.max_delta {
483            draw_info.max_delta = delta;
484        } else if delta < draw_info.min_delta {
485            draw_info.min_delta = delta;
486        }
487
488        draw_info.points.push(Point {
489            x: WIDTH,
490            y: delta, // Not the final value of y.
491            is_best_segment: false,
492        });
493        draw_info.is_live_delta_active = true;
494    }
495}
496
497/// Calculates the size of the chart's padding and its vertical scale factor.
498/// The padding is an area at the top/bottom that stays empty so that the graph
499/// doesn't touch the edge of the chart. This value depends on
500/// `min_`/`max_delta` because otherwise the scale factor would be huge for
501/// small graphs (graphs with only small deltas).
502fn calculate_vertical_scaling(draw_info: &mut DrawInfo) {
503    const MIN_PADDING: f32 = HEIGHT / 24.0;
504    const MAX_CONTENT_HEIGHT: f32 = HEIGHT - MIN_PADDING * 2.0;
505    // The bigger this value, the longer it will take for padding_y to get close
506    // to MIN_PADDING.
507    const SMOOTHNESS: f32 = 0.2;
508
509    let total_delta = draw_info.max_delta - draw_info.min_delta;
510    if total_delta > 0.0 {
511        // A hyperbola works well, this looks something like f(x) = 1/(x + 2)
512        draw_info.padding_y =
513            MAX_CONTENT_HEIGHT * SMOOTHNESS / (total_delta + SMOOTHNESS * 2.0) + MIN_PADDING;
514
515        let content_height = HEIGHT - draw_info.padding_y * 2.0;
516        draw_info.scale_factor_y = Some(content_height / total_delta);
517    }
518
519    // Else padding_y stays 0 and scale_factor_y stays None, because vertical
520    // scaling doesn't matter if all the points are at y=0.
521}
522
523fn calculate_x_axis(draw_info: &DrawInfo) -> f32 {
524    if let Some(scale_factor_y) = draw_info.scale_factor_y {
525        let x_axis = draw_info.max_delta * scale_factor_y + draw_info.padding_y;
526        if draw_info.flip_graph {
527            HEIGHT - x_axis
528        } else {
529            x_axis
530        }
531    } else {
532        DEFAULT_X_AXIS
533    }
534}
535
536fn calculate_grid_lines(draw_info: &DrawInfo, x_axis: f32) -> GridLines {
537    // Initially, the grid lines are all one second apart. Once a certain amount
538    // of lines is on screen, that number of seconds increases.
539
540    // When to reduce the amount of grid lines.
541    const REDUCE_LINES_THRESHOLD_HORIZONTAL: f32 = HEIGHT / 6.0;
542    const REDUCE_LINES_THRESHOLD_VERTICAL: f32 = HEIGHT / 9.0;
543    // How much bigger the distance between the lines should get.
544    const LINE_DISTANCE_FACTOR: f32 = 6.0;
545
546    let mut ret = GridLines::default();
547    if let Some(scale_factor_y) = draw_info.scale_factor_y {
548        let mut distance = scale_factor_y;
549        while distance < REDUCE_LINES_THRESHOLD_HORIZONTAL {
550            distance *= LINE_DISTANCE_FACTOR;
551        }
552
553        // The x-axis should always be on a grid line.
554        let offset = x_axis % distance;
555
556        ret.horizontal = Some((offset, distance));
557    } else {
558        // Show just one grid line, the x-axis.
559        ret.horizontal = Some((DEFAULT_X_AXIS, f32::INFINITY));
560    }
561
562    if let Some(scale_factor_x) = draw_info.scale_factor_x {
563        let mut distance = scale_factor_x;
564        while distance < REDUCE_LINES_THRESHOLD_VERTICAL {
565            distance *= LINE_DISTANCE_FACTOR;
566        }
567
568        ret.vertical = Some(distance);
569    }
570
571    ret
572}
573
574/// Copies the information from `grid_lines` into `Vec`s.
575fn update_grid_line_vecs(state: &mut State, grid_lines: GridLines) {
576    state.horizontal_grid_lines.clear();
577    if let Some((offset, distance)) = grid_lines.horizontal {
578        let mut y = offset;
579        while y < HEIGHT {
580            state.horizontal_grid_lines.push(y);
581            y += distance;
582        }
583    }
584
585    state.vertical_grid_lines.clear();
586    if let Some(distance) = grid_lines.vertical {
587        let mut x = distance;
588        while x < WIDTH {
589            state.vertical_grid_lines.push(x);
590            x += distance;
591        }
592    }
593}
594
595/// Before calling this function, the deltas are stored as the points'.
596/// y-coordinates. This will calculate the actual y-coordinates and replace the
597/// deltas. The reason why this can't be done in the first loop is that
598/// `min_`/`max_delta` is not known yet at that point in time.
599fn transform_y_coordinates(draw_info: &mut DrawInfo) {
600    if let Some(scale_factor_y) = draw_info.scale_factor_y {
601        for point in &mut draw_info.points {
602            let delta = point.y;
603            point.y = (draw_info.max_delta - delta) * scale_factor_y + draw_info.padding_y;
604            if draw_info.flip_graph {
605                point.y = HEIGHT - point.y;
606            }
607        }
608    } else {
609        for point in &mut draw_info.points {
610            point.y = DEFAULT_X_AXIS;
611        }
612    }
613}