From f51152e628ef81190de5869c39457aee061fd816 Mon Sep 17 00:00:00 2001
From: Alexander Senier <mail@senier.net>
Date: Wed, 20 Nov 2024 15:26:23 +0100
Subject: [PATCH] Refactor chart plotting

---
 frontend/src/ui/common.rs               | 433 ++++++++++--------------
 frontend/src/ui/page/body_fat.rs        |  35 +-
 frontend/src/ui/page/body_weight.rs     |  22 +-
 frontend/src/ui/page/exercise.rs        |  89 +++--
 frontend/src/ui/page/menstrual_cycle.rs |  19 +-
 frontend/src/ui/page/muscles.rs         |  10 +-
 frontend/src/ui/page/routine.rs         |  36 +-
 frontend/src/ui/page/training.rs        |  47 ++-
 8 files changed, 322 insertions(+), 369 deletions(-)

diff --git a/frontend/src/ui/common.rs b/frontend/src/ui/common.rs
index ec8fde6..c6a87ac 100644
--- a/frontend/src/ui/common.rs
+++ b/frontend/src/ui/common.rs
@@ -1,4 +1,7 @@
-use std::collections::{BTreeMap, HashMap};
+use std::{
+    borrow::BorrowMut,
+    collections::{BTreeMap, HashMap},
+};
 
 use chrono::{prelude::*, Duration};
 use plotters::prelude::*;
@@ -25,6 +28,77 @@ pub const COLOR_REPS_RIR: usize = 4;
 pub const COLOR_WEIGHT: usize = 8;
 pub const COLOR_TIME: usize = 5;
 
+#[derive(Clone)]
+pub enum PlotType {
+    Circle(usize, u32),
+    Line(usize, u32),
+    Histogram(usize),
+}
+
+pub fn plot_line_with_dots(color: usize) -> Vec<PlotType> {
+    [PlotType::Line(color, 2), PlotType::Circle(color, 2)].to_vec()
+}
+
+#[derive(Default)]
+pub struct PlotParams {
+    pub y_min_opt: Option<f32>,
+    pub y_max_opt: Option<f32>,
+    pub secondary: bool,
+    pub has_y_margin: bool,
+}
+
+impl PlotParams {
+    pub fn default() -> Self {
+        Self {
+            y_min_opt: None,
+            y_max_opt: None,
+            secondary: false,
+            has_y_margin: true,
+        }
+    }
+
+    pub fn primary_range(min: f32, max: f32) -> Self {
+        Self {
+            y_min_opt: Some(min),
+            y_max_opt: Some(max),
+            secondary: false,
+            has_y_margin: true,
+        }
+    }
+
+    pub const SECONDARY: Self = Self {
+        y_max_opt: None,
+        y_min_opt: None,
+        secondary: true,
+        has_y_margin: true,
+    };
+}
+
+pub struct PlotData {
+    pub values: Vec<(NaiveDate, f32)>,
+    pub plots: Vec<PlotType>,
+    pub params: PlotParams,
+}
+
+#[derive(Clone, Copy, Default)]
+pub struct Bounds {
+    min: f32,
+    max: f32,
+    has_margin: bool,
+}
+
+impl Bounds {
+    fn margin(self) -> f32 {
+        if !self.has_margin || self.min <= f32::EPSILON {
+            return 0.;
+        }
+        if (self.max - self.min).abs() > f32::EPSILON {
+            return (self.max - self.min) * 0.1;
+        }
+        0.1
+    }
+}
+
 pub struct Interval {
     pub first: NaiveDate,
     pub last: NaiveDate,
@@ -647,25 +721,19 @@ pub fn view_chart<Ms>(
     }
 }
 
-pub fn plot_line_chart(
-    data: &[(Vec<(NaiveDate, f32)>, usize)],
+pub fn plot_chart(
+    data: &[PlotData],
     x_min: NaiveDate,
     x_max: NaiveDate,
-    y_min_opt: Option<f32>,
-    y_max_opt: Option<f32>,
     theme: &data::Theme,
 ) -> Result<Option<String>, Box<dyn std::error::Error>> {
     if all_zeros(data) {
         return Ok(None);
     }
 
-    let (y_min, y_max, y_margin) = determine_y_bounds(
-        data.iter()
-            .flat_map(|(s, _)| s.iter().map(|(_, y)| *y))
-            .collect::<Vec<_>>(),
-        y_min_opt,
-        y_max_opt,
-    );
+    let (Some(primary_bounds), secondary_bounds) = determine_y_bounds(data) else {
+        return Ok(None);
+    };
 
     let mut result = String::new();
 
@@ -681,196 +749,22 @@ pub fn plot_line_chart(
             .x_label_area_size(30f32)
             .y_label_area_size(40f32);
 
-        let mut chart = chart_builder.build_cartesian_2d(
-            x_min..x_max,
-            f32::max(0., y_min - y_margin)..y_max + y_margin,
-        )?;
-
-        chart
-            .configure_mesh()
-            .disable_x_mesh()
-            .set_all_tick_mark_size(3u32)
-            .axis_style(color.mix(0.3))
-            .bold_line_style(color.mix(0.05))
-            .light_line_style(color.mix(0.0))
-            .label_style(&color)
-            .x_labels(2)
-            .y_labels(6)
-            .draw()?;
-
-        for (series, color_idx) in data {
-            let mut series = series.iter().collect::<Vec<_>>();
-            series.sort_by_key(|e| e.0);
-            let color = Palette99::pick(*color_idx).mix(0.9);
-
-            chart.draw_series(LineSeries::new(
-                series.iter().map(|(x, y)| (*x, *y)),
-                color.stroke_width(2),
-            ))?;
-
-            chart.draw_series(
-                series
-                    .iter()
-                    .map(|(x, y)| Circle::new((*x, *y), 2, color.filled())),
-            )?;
-        }
-
-        root.present()?;
-    }
-
-    Ok(Some(result))
-}
-
-pub fn plot_dual_line_chart(
-    data: &[(Vec<(NaiveDate, f32)>, usize)],
-    secondary_data: &[(Vec<(NaiveDate, f32)>, usize)],
-    x_min: NaiveDate,
-    x_max: NaiveDate,
-    theme: &data::Theme,
-) -> Result<Option<String>, Box<dyn std::error::Error>> {
-    if all_zeros(data) && all_zeros(secondary_data) {
-        return Ok(None);
-    }
-
-    let (y1_min, y1_max, y1_margin) = determine_y_bounds(
-        data.iter()
-            .flat_map(|(s, _)| s.iter().map(|(_, y)| *y))
-            .collect::<Vec<_>>(),
-        None,
-        None,
-    );
-    let (y2_min, y2_max, y2_margin) = determine_y_bounds(
-        secondary_data
-            .iter()
-            .flat_map(|(s, _)| s.iter().map(|(_, y)| *y))
-            .collect::<Vec<_>>(),
-        None,
-        None,
-    );
-
-    let mut result = String::new();
-
-    {
-        let root = SVGBackend::with_string(&mut result, (chart_width(), 200)).into_drawing_area();
-        let (color, background_color) = colors(theme);
-
-        root.fill(&background_color)?;
-
-        let mut chart = ChartBuilder::on(&root)
-            .margin(10f32)
-            .x_label_area_size(30f32)
-            .y_label_area_size(40f32)
-            .right_y_label_area_size(40f32)
-            .build_cartesian_2d(x_min..x_max, y1_min - y1_margin..y1_max + y1_margin)?
-            .set_secondary_coord(x_min..x_max, y2_min - y2_margin..y2_max + y2_margin);
-
-        chart
-            .configure_mesh()
-            .disable_x_mesh()
-            .set_all_tick_mark_size(3u32)
-            .axis_style(color.mix(0.3))
-            .bold_line_style(color.mix(0.05))
-            .light_line_style(color.mix(0.0))
-            .label_style(&color)
-            .x_labels(2)
-            .y_labels(6)
-            .draw()?;
-
-        chart
-            .configure_secondary_axes()
-            .set_all_tick_mark_size(3u32)
-            .axis_style(color.mix(0.3))
-            .label_style(&color)
-            .draw()?;
-
-        for (series, color_idx) in secondary_data {
-            let mut series = series.iter().collect::<Vec<_>>();
-            series.sort_by_key(|e| e.0);
-            let color = Palette99::pick(*color_idx).mix(0.9);
-
-            chart.draw_secondary_series(LineSeries::new(
-                series.iter().map(|(x, y)| (*x, *y)),
-                color.stroke_width(2),
-            ))?;
-
-            chart.draw_secondary_series(
-                series
-                    .iter()
-                    .map(|(x, y)| Circle::new((*x, *y), 2, color.filled())),
-            )?;
-        }
-
-        for (series, color_idx) in data {
-            let mut series = series.iter().collect::<Vec<_>>();
-            series.sort_by_key(|e| e.0);
-            let color = Palette99::pick(*color_idx).mix(0.9);
-
-            chart.draw_series(LineSeries::new(
-                series.iter().map(|(x, y)| (*x, *y)),
-                color.stroke_width(2),
-            ))?;
-
-            chart.draw_series(
-                series
-                    .iter()
-                    .map(|(x, y)| Circle::new((*x, *y), 2, color.filled())),
-            )?;
-        }
-
-        root.present()?;
-    }
-
-    Ok(Some(result))
-}
-
-pub fn plot_bar_chart(
-    data: &[(Vec<(NaiveDate, f32)>, usize)],
-    secondary_data: &[(Vec<(NaiveDate, f32)>, usize)],
-    x_min: NaiveDate,
-    x_max: NaiveDate,
-    y_min_opt: Option<f32>,
-    y_max_opt: Option<f32>,
-    theme: &data::Theme,
-) -> Result<Option<String>, Box<dyn std::error::Error>> {
-    if all_zeros(data) && all_zeros(secondary_data) {
-        return Ok(None);
-    }
-
-    let (y1_min, y1_max, _) = determine_y_bounds(
-        data.iter()
-            .flat_map(|(s, _)| s.iter().map(|(_, y)| *y))
-            .collect::<Vec<_>>(),
-        y_min_opt,
-        y_max_opt,
-    );
-    let y1_margin = 0.;
-    let (y2_min, y2_max, y2_margin) = determine_y_bounds(
-        secondary_data
-            .iter()
-            .flat_map(|(s, _)| s.iter().map(|(_, y)| *y))
-            .collect::<Vec<_>>(),
-        None,
-        None,
-    );
-
-    let mut result = String::new();
-
-    {
-        let root = SVGBackend::with_string(&mut result, (chart_width(), 200)).into_drawing_area();
-        let (color, background_color) = colors(theme);
-
-        root.fill(&background_color)?;
-
         let mut chart = ChartBuilder::on(&root)
             .margin(10f32)
             .x_label_area_size(30f32)
             .y_label_area_size(40f32)
-            .right_y_label_area_size(30f32)
+            .right_y_label_area_size(secondary_bounds.map_or_else(|| 0f32, |_| 40f32))
             .build_cartesian_2d(
-                (x_min..x_max).into_segmented(),
-                y1_min - y1_margin..y1_max + y1_margin,
+                x_min..x_max,
+                primary_bounds.min - primary_bounds.margin()
+                    ..primary_bounds.max + primary_bounds.margin(),
             )?
-            .set_secondary_coord(x_min..x_max, y2_min - y2_margin..y2_max + y2_margin);
+            .set_secondary_coord(
+                x_min..x_max,
+                secondary_bounds
+                    .as_ref()
+                    .map_or(0.0..0.0, |b| b.min - b.margin()..b.max + b.margin()),
+            );
 
         chart
             .configure_mesh()
@@ -884,40 +778,65 @@ pub fn plot_bar_chart(
             .y_labels(6)
             .draw()?;
 
-        chart
-            .configure_secondary_axes()
-            .set_all_tick_mark_size(3u32)
-            .axis_style(color.mix(0.3))
-            .label_style(&color)
-            .draw()?;
-
-        for (series, color_idx) in data {
-            let mut series = series.iter().collect::<Vec<_>>();
-            series.sort_by_key(|e| e.0);
-            let color = Palette99::pick(*color_idx).mix(0.9).filled();
-            let histogram = Histogram::vertical(&chart)
-                .style(color)
-                .margin(0) // https://github.com/plotters-rs/plotters/issues/300
-                .data(series.iter().map(|(x, y)| (*x, *y)));
-
-            chart.draw_series(histogram)?;
+        if secondary_bounds.is_some() {
+            chart
+                .configure_secondary_axes()
+                .set_all_tick_mark_size(3u32)
+                .axis_style(color.mix(0.3))
+                .label_style(&color)
+                .draw()?;
         }
 
-        for (series, color_idx) in secondary_data {
-            let mut series = series.iter().collect::<Vec<_>>();
-            series.sort_by_key(|e| e.0);
-            let color = Palette99::pick(*color_idx).mix(0.9);
-
-            chart.draw_secondary_series(LineSeries::new(
-                series.iter().map(|(x, y)| (*x, *y)),
-                color.stroke_width(2),
-            ))?;
-
-            chart.draw_secondary_series(
-                series
-                    .iter()
-                    .map(|(x, y)| Circle::new((*x, *y), 2, color.filled())),
-            )?;
+        for secondary in [false, true] {
+            for data in data.iter().filter(|d| d.params.secondary == secondary) {
+                let mut series = data.values.iter().collect::<Vec<_>>();
+                series.sort_by_key(|e| e.0);
+
+                for plot in &data.plots {
+                    match *plot {
+                        PlotType::Circle(color, size) => {
+                            let data = series
+                                .iter()
+                                .map(|(x, y)| {
+                                    Circle::new(
+                                        (*x, *y),
+                                        size,
+                                        Palette99::pick(color).mix(0.9).filled(),
+                                    )
+                                })
+                                .collect::<Vec<_>>();
+                            if secondary {
+                                chart.draw_secondary_series(data)?
+                            } else {
+                                chart.draw_series(data)?
+                            }
+                        }
+                        PlotType::Line(color, size) => {
+                            let data = LineSeries::new(
+                                series.iter().map(|(x, y)| (*x, *y)),
+                                Palette99::pick(color).mix(0.9).stroke_width(size),
+                            );
+                            if secondary {
+                                chart.draw_secondary_series(data)?
+                            } else {
+                                chart.draw_series(data)?
+                            }
+                        }
+                        PlotType::Histogram(color) => {
+                            let data = Histogram::vertical(&chart)
+                                .style(Palette99::pick(color).mix(0.9).filled())
+                                .margin(0) // https://github.com/plotters-rs/plotters/issues/300
+                                .data(series.iter().map(|(x, y)| (*x, *y)));
+
+                            if secondary {
+                                chart.draw_secondary_series(data)?
+                            } else {
+                                chart.draw_series(data)?
+                            }
+                        }
+                    };
+                }
+            }
         }
 
         root.present()?;
@@ -926,8 +845,11 @@ pub fn plot_bar_chart(
     Ok(Some(result))
 }
 
-fn all_zeros(data: &[(Vec<(NaiveDate, f32)>, usize)]) -> bool {
-    return data.iter().all(|p| p.0.iter().all(|s| s.1 == 0.0));
+fn all_zeros(data: &[PlotData]) -> bool {
+    data.iter()
+        .map(|v| v.values.iter().all(|(_, v)| *v == 0.0))
+        .reduce(|l, r| l && r)
+        .unwrap_or(true)
 }
 
 fn colors(theme: &data::Theme) -> (RGBColor, RGBColor) {
@@ -938,26 +860,39 @@ fn colors(theme: &data::Theme) -> (RGBColor, RGBColor) {
     }
 }
 
-fn determine_y_bounds(
-    y: Vec<f32>,
-    y_min_opt: Option<f32>,
-    y_max_opt: Option<f32>,
-) -> (f32, f32, f32) {
-    let y_min = f32::min(
-        y_min_opt.unwrap_or(f32::MAX),
-        y.clone().into_iter().reduce(f32::min).unwrap_or(0.),
-    );
-    let y_max = f32::max(
-        y_max_opt.unwrap_or(0.),
-        y.into_iter().reduce(f32::max).unwrap_or(0.),
-    );
-    let y_margin = if (y_max - y_min).abs() > f32::EPSILON || y_min == 0. {
-        (y_max - y_min) * 0.1
-    } else {
-        0.1
-    };
+fn determine_y_bounds(data: &[PlotData]) -> (Option<Bounds>, Option<Bounds>) {
+    let mut primary_bounds: Option<Bounds> = None;
+    let mut secondary_bounds: Option<Bounds> = None;
+
+    for plot in data {
+        let min = plot
+            .values
+            .iter()
+            .map(|(_, v)| *v)
+            .fold(plot.params.y_min_opt.unwrap_or(f32::MAX), f32::min);
+        let max = plot
+            .values
+            .iter()
+            .map(|(_, v)| *v)
+            .fold(plot.params.y_max_opt.unwrap_or(0.), f32::max);
+
+        let b = if plot.params.secondary {
+            secondary_bounds.borrow_mut()
+        } else {
+            primary_bounds.borrow_mut()
+        }
+        .get_or_insert(Bounds {
+            min,
+            max,
+            has_margin: plot.params.has_y_margin,
+        });
+
+        b.min = f32::min(b.min, min);
+        b.max = f32::max(b.max, max);
+        b.has_margin = b.has_margin || plot.params.has_y_margin;
+    }
 
-    (y_min, y_max, y_margin)
+    (primary_bounds, secondary_bounds)
 }
 
 fn chart_width() -> u32 {
diff --git a/frontend/src/ui/page/body_fat.rs b/frontend/src/ui/page/body_fat.rs
index 5df8bdb..0fc2f09 100644
--- a/frontend/src/ui/page/body_fat.rs
+++ b/frontend/src/ui/page/body_fat.rs
@@ -742,30 +742,33 @@ fn view_chart(model: &Model, data_model: &data::Model) -> Node<Msg> {
             ("Weight (kg)", common::COLOR_BODY_WEIGHT),
         ]
         .as_slice(),
-        common::plot_dual_line_chart(
+        common::plot_chart(
             &[
-                (
-                    body_fat
+                common::PlotData {
+                    values: body_fat
                         .iter()
                         .filter_map(|bf| bf.jp3(sex).map(|jp3| (bf.date, jp3)))
                         .collect::<Vec<_>>(),
-                    common::COLOR_BODY_FAT_JP3,
-                ),
-                (
-                    body_fat
+                    plots: common::plot_line_with_dots(common::COLOR_BODY_FAT_JP3),
+                    params: common::PlotParams::default(),
+                },
+                common::PlotData {
+                    values: body_fat
                         .iter()
                         .filter_map(|bf| bf.jp7(sex).map(|jp7| (bf.date, jp7)))
                         .collect::<Vec<_>>(),
-                    common::COLOR_BODY_FAT_JP7,
-                ),
+                    plots: common::plot_line_with_dots(common::COLOR_BODY_FAT_JP7),
+                    params: common::PlotParams::default(),
+                },
+                common::PlotData {
+                    values: body_weight
+                        .iter()
+                        .map(|bw| (bw.date, bw.weight))
+                        .collect::<Vec<_>>(),
+                    plots: common::plot_line_with_dots(common::COLOR_BODY_WEIGHT),
+                    params: common::PlotParams::SECONDARY,
+                },
             ],
-            &[(
-                body_weight
-                    .iter()
-                    .map(|bw| (bw.date, bw.weight))
-                    .collect::<Vec<_>>(),
-                common::COLOR_BODY_WEIGHT,
-            )],
             model.interval.first,
             model.interval.last,
             data_model.theme(),
diff --git a/frontend/src/ui/page/body_weight.rs b/frontend/src/ui/page/body_weight.rs
index a043f73..67182c5 100644
--- a/frontend/src/ui/page/body_weight.rs
+++ b/frontend/src/ui/page/body_weight.rs
@@ -361,10 +361,10 @@ fn view_chart(model: &Model, data_model: &data::Model) -> Node<Msg> {
             ("Avg. weight (kg)", common::COLOR_AVG_BODY_WEIGHT),
         ]
         .as_slice(),
-        common::plot_line_chart(
+        common::plot_chart(
             &[
-                (
-                    data_model
+                common::PlotData {
+                    values: data_model
                         .body_weight
                         .values()
                         .filter(|bw| {
@@ -372,10 +372,11 @@ fn view_chart(model: &Model, data_model: &data::Model) -> Node<Msg> {
                         })
                         .map(|bw| (bw.date, bw.weight))
                         .collect::<Vec<_>>(),
-                    common::COLOR_BODY_WEIGHT,
-                ),
-                (
-                    data_model
+                    plots: common::plot_line_with_dots(common::COLOR_BODY_WEIGHT),
+                    params: common::PlotParams::default(),
+                },
+                common::PlotData {
+                    values: data_model
                         .body_weight_stats
                         .values()
                         .filter(|bws| {
@@ -383,13 +384,12 @@ fn view_chart(model: &Model, data_model: &data::Model) -> Node<Msg> {
                         })
                         .filter_map(|bws| bws.avg_weight.map(|avg_weight| (bws.date, avg_weight)))
                         .collect::<Vec<_>>(),
-                    common::COLOR_AVG_BODY_WEIGHT,
-                ),
+                    plots: common::plot_line_with_dots(common::COLOR_AVG_BODY_WEIGHT),
+                    params: common::PlotParams::default(),
+                },
             ],
             model.interval.first,
             model.interval.last,
-            None,
-            None,
             data_model.theme(),
         ),
         true,
diff --git a/frontend/src/ui/page/exercise.rs b/frontend/src/ui/page/exercise.rs
index d2d256c..b15cbaa 100644
--- a/frontend/src/ui/page/exercise.rs
+++ b/frontend/src/ui/page/exercise.rs
@@ -501,21 +501,22 @@ pub fn view_charts<Ms>(
 
     let mut labels = vec![("Repetitions", common::COLOR_REPS)];
 
-    let mut data = vec![(
-        reps_rpe
+    let mut data = vec![common::PlotData {
+        values: reps_rpe
             .iter()
             .map(|(date, (avg_reps, _))| {
                 #[allow(clippy::cast_precision_loss)]
                 (*date, avg_reps.iter().sum::<f32>() / avg_reps.len() as f32)
             })
             .collect::<Vec<_>>(),
-        common::COLOR_REPS,
-    )];
+        plots: common::plot_line_with_dots(common::COLOR_REPS),
+        params: common::PlotParams::primary_range(0., 10.),
+    }];
 
     if show_rpe {
         labels.push(("+ Repetitions in reserve", common::COLOR_REPS_RIR));
-        data.push((
-            reps_rpe
+        data.push(common::PlotData {
+            values: reps_rpe
                 .into_iter()
                 .filter_map(|(date, (avg_reps_values, avg_rpe_values))| {
                     #[allow(clippy::cast_precision_loss)]
@@ -530,37 +531,36 @@ pub fn view_charts<Ms>(
                     }
                 })
                 .collect::<Vec<_>>(),
-            common::COLOR_REPS_RIR,
-        ));
+            plots: common::plot_line_with_dots(common::COLOR_REPS_RIR),
+            params: common::PlotParams::primary_range(0., 10.),
+        });
     }
 
     nodes![
         common::view_chart(
             &[("Set volume", common::COLOR_SET_VOLUME)],
-            common::plot_line_chart(
-                &[(
-                    set_volume.into_iter().collect::<Vec<_>>(),
-                    common::COLOR_SET_VOLUME,
-                )],
+            common::plot_chart(
+                &[common::PlotData {
+                    values: set_volume.into_iter().collect::<Vec<_>>(),
+                    plots: common::plot_line_with_dots(common::COLOR_SET_VOLUME),
+                    params: common::PlotParams::primary_range(0., 10.),
+                }],
                 interval.first,
                 interval.last,
-                Some(0.),
-                Some(10.),
                 theme,
             ),
             false,
         ),
         common::view_chart(
             &[("Volume load", common::COLOR_VOLUME_LOAD)],
-            common::plot_line_chart(
-                &[(
-                    volume_load.into_iter().collect::<Vec<_>>(),
-                    common::COLOR_VOLUME_LOAD,
-                )],
+            common::plot_chart(
+                &[common::PlotData {
+                    values: volume_load.into_iter().collect::<Vec<_>>(),
+                    plots: common::plot_line_with_dots(common::COLOR_VOLUME_LOAD),
+                    params: common::PlotParams::primary_range(0., 10.),
+                }],
                 interval.first,
                 interval.last,
-                Some(0.),
-                Some(10.),
                 theme,
             ),
             false,
@@ -568,12 +568,14 @@ pub fn view_charts<Ms>(
         IF![show_tut =>
             common::view_chart(
                 &[("Time under tension (s)", common::COLOR_TUT)],
-                common::plot_line_chart(
-                    &[(tut.into_iter().collect::<Vec<_>>(), common::COLOR_TUT,)],
+                common::plot_chart(
+                    &[common::PlotData {
+                        values: tut.into_iter().collect::<Vec<_>>(),
+                        plots: common::plot_line_with_dots(common::COLOR_TUT),
+                        params: common::PlotParams::primary_range(0., 10.),
+                    }],
                     interval.first,
                     interval.last,
-                    Some(0.),
-                    Some(10.),
                     theme,
                 ),
                 false,
@@ -581,33 +583,25 @@ pub fn view_charts<Ms>(
         ],
         common::view_chart(
             &labels,
-            common::plot_line_chart(
-                &data,
-                interval.first,
-                interval.last,
-                Some(0.),
-                Some(10.),
-                theme,
-            ),
+            common::plot_chart(&data, interval.first, interval.last, theme),
             false,
         ),
         common::view_chart(
             &[("Weight (kg)", common::COLOR_WEIGHT)],
-            common::plot_line_chart(
-                &[(
-                    weight
+            common::plot_chart(
+                &[common::PlotData {
+                    values: weight
                         .into_iter()
                         .map(|(date, values)| {
                             #[allow(clippy::cast_precision_loss)]
                             (date, values.iter().sum::<f32>() / values.len() as f32)
                         })
                         .collect::<Vec<_>>(),
-                    common::COLOR_WEIGHT,
-                )],
+                    plots: common::plot_line_with_dots(common::COLOR_WEIGHT),
+                    params: common::PlotParams::primary_range(0., 10.),
+                }],
                 interval.first,
                 interval.last,
-                Some(0.),
-                Some(10.),
                 theme,
             ),
             false,
@@ -615,20 +609,19 @@ pub fn view_charts<Ms>(
         IF![show_tut =>
             common::view_chart(
                 &[("Time (s)", common::COLOR_TIME)],
-                common::plot_line_chart(
-                    &[(
-                        time.into_iter()
+                common::plot_chart(
+                    &[common::PlotData{
+                        values: time.into_iter()
                             .map(|(date, values)| {
                                 #[allow(clippy::cast_precision_loss)]
                                 (date, values.iter().sum::<f32>() / values.len() as f32)
                             })
                             .collect::<Vec<_>>(),
-                        common::COLOR_TIME,
-                    )],
+                        plots: common::plot_line_with_dots(common::COLOR_TIME),
+                        params: common::PlotParams::primary_range(0., 10.)
+                    }],
                     interval.first,
                     interval.last,
-                    Some(0.),
-                    Some(10.),
                     theme,
                 ),
                 false,
diff --git a/frontend/src/ui/page/menstrual_cycle.rs b/frontend/src/ui/page/menstrual_cycle.rs
index f66e29c..1a6662c 100644
--- a/frontend/src/ui/page/menstrual_cycle.rs
+++ b/frontend/src/ui/page/menstrual_cycle.rs
@@ -345,19 +345,22 @@ fn view_chart(model: &Model, data_model: &data::Model) -> Node<Msg> {
 
     common::view_chart(
         vec![("Intensity", common::COLOR_PERIOD_INTENSITY)].as_slice(),
-        common::plot_bar_chart(
-            &[(
-                period
+        common::plot_chart(
+            &[common::PlotData {
+                values: period
                     .iter()
                     .map(|p| (p.date, f32::from(p.intensity)))
                     .collect::<Vec<_>>(),
-                common::COLOR_PERIOD_INTENSITY,
-            )],
-            &[],
+                plots: [common::PlotType::Histogram(common::COLOR_PERIOD_INTENSITY)].to_vec(),
+                params: common::PlotParams {
+                    y_min_opt: Some(0.),
+                    y_max_opt: Some(4.),
+                    secondary: false,
+                    has_y_margin: false,
+                },
+            }],
             model.interval.first,
             model.interval.last,
-            Some(0.),
-            Some(4.),
             data_model.theme(),
         ),
         true,
diff --git a/frontend/src/ui/page/muscles.rs b/frontend/src/ui/page/muscles.rs
index 4ca5e34..b88feec 100644
--- a/frontend/src/ui/page/muscles.rs
+++ b/frontend/src/ui/page/muscles.rs
@@ -97,12 +97,14 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node<Msg> {
                     ],
                     common::view_chart(
                         &[("Set volume (weekly total)", common::COLOR_SET_VOLUME)],
-                        common::plot_line_chart(
-                            &[(set_volume, common::COLOR_SET_VOLUME)],
+                        common::plot_chart(
+                            &[common::PlotData {
+                                values: set_volume,
+                                plots: common::plot_line_with_dots(common::COLOR_SET_VOLUME),
+                                params: common::PlotParams::primary_range(0., 10.),
+                            }],
                             model.interval.first,
                             model.interval.last,
-                            Some(0.),
-                            Some(10.),
                             data_model.theme()
                         ),
                         true,
diff --git a/frontend/src/ui/page/routine.rs b/frontend/src/ui/page/routine.rs
index 0bbfc04..93570fc 100644
--- a/frontend/src/ui/page/routine.rs
+++ b/frontend/src/ui/page/routine.rs
@@ -1338,27 +1338,28 @@ pub fn view_charts<Ms>(
     nodes![
         common::view_chart(
             &[("Load", common::COLOR_LOAD)],
-            common::plot_line_chart(
-                &[(load.into_iter().collect::<Vec<_>>(), common::COLOR_LOAD)],
+            common::plot_chart(
+                &[common::PlotData {
+                    values: load.into_iter().collect::<Vec<_>>(),
+                    plots: common::plot_line_with_dots(common::COLOR_LOAD),
+                    params: common::PlotParams::primary_range(0., 10.),
+                }],
                 interval.first,
                 interval.last,
-                Some(0.),
-                Some(10.),
                 theme,
             ),
             false,
         ),
         common::view_chart(
             &[("Set volume", common::COLOR_SET_VOLUME)],
-            common::plot_line_chart(
-                &[(
-                    set_volume.into_iter().collect::<Vec<_>>(),
-                    common::COLOR_SET_VOLUME,
-                )],
+            common::plot_chart(
+                &[common::PlotData {
+                    values: set_volume.into_iter().collect::<Vec<_>>(),
+                    plots: common::plot_line_with_dots(common::COLOR_SET_VOLUME),
+                    params: common::PlotParams::primary_range(0., 10.),
+                }],
                 interval.first,
                 interval.last,
-                Some(0.),
-                Some(10.),
                 theme,
             ),
             false,
@@ -1367,9 +1368,9 @@ pub fn view_charts<Ms>(
             show_rpe =>
             common::view_chart(
                 &[("RPE", common::COLOR_RPE)],
-                common::plot_line_chart(
-                    &[(
-                        rpe.into_iter()
+                common::plot_chart(
+                    &[common::PlotData{
+                        values: rpe.into_iter()
                             .map(|(date, values)| {
                                 #[allow(clippy::cast_precision_loss)]
                                 (
@@ -1382,12 +1383,11 @@ pub fn view_charts<Ms>(
                                 )
                             })
                             .collect::<Vec<_>>(),
-                        common::COLOR_RPE,
-                    )],
+                        plots: common::plot_line_with_dots(common::COLOR_RPE),
+                        params: common::PlotParams::primary_range(5., 10.)
+                    }],
                     interval.first,
                     interval.last,
-                    Some(5.),
-                    Some(10.),
                     theme,
                 ),
                 false,
diff --git a/frontend/src/ui/page/training.rs b/frontend/src/ui/page/training.rs
index 8049451..43b0806 100644
--- a/frontend/src/ui/page/training.rs
+++ b/frontend/src/ui/page/training.rs
@@ -528,29 +528,45 @@ pub fn view_charts<Ms>(
                 ("Short-term load", common::COLOR_LOAD),
                 ("Long-term load", common::COLOR_LONG_TERM_LOAD)
             ],
-            common::plot_line_chart(
+            common::plot_chart(
                 &[
-                    (long_term_load_low, common::COLOR_LONG_TERM_LOAD_BOUNDS),
-                    (long_term_load_high, common::COLOR_LONG_TERM_LOAD_BOUNDS),
-                    (long_term_load, common::COLOR_LONG_TERM_LOAD),
-                    (short_term_load, common::COLOR_LOAD)
+                    common::PlotData {
+                        values: long_term_load_low,
+                        plots: common::plot_line_with_dots(common::COLOR_LONG_TERM_LOAD_BOUNDS),
+                        params: common::PlotParams::primary_range(0., 10.),
+                    },
+                    common::PlotData {
+                        values: long_term_load_high,
+                        plots: common::plot_line_with_dots(common::COLOR_LONG_TERM_LOAD_BOUNDS),
+                        params: common::PlotParams::primary_range(0., 10.),
+                    },
+                    common::PlotData {
+                        values: long_term_load,
+                        plots: common::plot_line_with_dots(common::COLOR_LONG_TERM_LOAD),
+                        params: common::PlotParams::primary_range(0., 10.),
+                    },
+                    common::PlotData {
+                        values: short_term_load,
+                        plots: common::plot_line_with_dots(common::COLOR_LOAD),
+                        params: common::PlotParams::primary_range(0., 10.),
+                    }
                 ],
                 interval.first,
                 interval.last,
-                Some(0.),
-                Some(10.),
                 theme,
             ),
             false,
         ),
         common::view_chart(
             &[("Set volume (weekly total)", common::COLOR_SET_VOLUME)],
-            common::plot_line_chart(
-                &[(total_set_volume_per_week, common::COLOR_SET_VOLUME)],
+            common::plot_chart(
+                &[common::PlotData {
+                    values: total_set_volume_per_week,
+                    plots: common::plot_line_with_dots(common::COLOR_SET_VOLUME),
+                    params: common::PlotParams::primary_range(0., 10.),
+                }],
                 interval.first,
                 interval.last,
-                Some(0.),
-                Some(10.),
                 theme,
             ),
             false,
@@ -559,12 +575,13 @@ pub fn view_charts<Ms>(
             show_rpe =>
             common::view_chart(
                 &[("RPE (weekly average)", common::COLOR_RPE)],
-                common::plot_line_chart(
-                    &[(avg_rpe_per_week, common::COLOR_RPE)],
+                common::plot_chart(
+                    &[common::PlotData{values: avg_rpe_per_week,
+                        plots: common::plot_line_with_dots(common::COLOR_RPE),
+                        params: common::PlotParams::primary_range(5., 10.)
+                    }],
                     interval.first,
                     interval.last,
-                    Some(5.),
-                    Some(10.),
                     theme,
                 ),
                 false,