From ac2c4d31c2dc51ff71c281a16587d3d6c8848a7d Mon Sep 17 00:00:00 2001 From: Corey Alexander Date: Thu, 14 Mar 2024 22:48:43 -0400 Subject: [PATCH 1/2] Refactor to allow multiple lines easier --- src/routes/current_user/pages.rs | 212 ++++++++++++++++++++++++------- 1 file changed, 168 insertions(+), 44 deletions(-) diff --git a/src/routes/current_user/pages.rs b/src/routes/current_user/pages.rs index f928b77..6abfef2 100644 --- a/src/routes/current_user/pages.rs +++ b/src/routes/current_user/pages.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{fmt::Display, time::Duration}; use axum::{ extract::{Path, State}, @@ -182,6 +182,89 @@ struct YAxisLine { label: String, } +struct SvgPath { + points: Vec<(f64, f64)>, + stroke_width: f64, + path_class: String, + stroke_dashed: bool, +} + +impl Render for SvgPath { + fn render(&self) -> maud::Markup { + let Self { + points, + stroke_width, + path_class, + stroke_dashed, + } = self; + + let svg_path = points + .iter() + .enumerate() + .map(|(i, (x, y))| { + if i == 0 { + format!("M{x} {y}") + } else { + format!("L{x} {y}") + } + }) + .collect::>() + .join(" "); + + html! { + path + d=(svg_path) + fill="none" + class=(path_class) + stroke-width=(stroke_width) + stroke-dasharray=(if *stroke_dashed { "2" } else { "0" }) + {} + } + } +} + +struct SvgPathWithPoints { + points: Vec, + stroke_width: f64, + path_class: String, + stroke_dashed: bool, + point_radius: usize, + label_font_size: usize, + group_class: String, +} + +impl Render for SvgPathWithPoints { + fn render(&self) -> maud::Markup { + let Self { + point_radius, + label_font_size, + group_class, + points, + stroke_width, + path_class, + stroke_dashed, + } = self; + + html! { + (SvgPath { + points: points.iter().map(|p| (p.x, p.y)).collect(), + stroke_width: *stroke_width, + path_class: path_class.clone(), + stroke_dashed: *stroke_dashed, + }) + + @for GraphPoint { x, y, label } in points.iter() { + g class=(format!("group {group_class}")) { + circle cx=(x) cy=(y) r=(point_radius) class="fill-transparent stroke-transparent" {} + circle cx=(x) cy=(y) r=(point_radius / 2) {} + + text x=(x) y=(y + 5.0) font-size=(label_font_size) class="hidden group-hover:block" { (label) } + } + } + } + } +} + impl Render for YAxisLine { fn render(&self) -> maud::Markup { let Self { @@ -191,12 +274,69 @@ impl Render for YAxisLine { } = self; html! { - path d=(format!("M0 {0} L{width} {0}", y_pos)) fill="none" stroke="blue" stroke-dasharray="2" stroke-width="0.25" {} + (SvgPath { + points: vec![ + (0.0,*y_pos as f64), + (*width as f64, *y_pos as f64), + ], + stroke_width: 0.25, + stroke_dashed: true, + path_class: "stroke-blue-500".to_string(), + }) text x=(0) y=(y_pos) font-size="5" fill="blue" { (label) } } } } +trait ToFloat { + fn to_f64(&self) -> f64; +} + +impl ToFloat for i64 { + fn to_f64(&self) -> f64 { + *self as f64 + } +} + +impl ToFloat for Duration { + fn to_f64(&self) -> f64 { + self.as_nanos() as f64 + } +} + +impl ToFloat for chrono::Duration { + fn to_f64(&self) -> f64 { + self.to_std().unwrap().to_f64() + } +} + +fn calculate_range_percentile(range: &std::ops::Range, item: T) -> f64 +where + T: PartialOrd + std::ops::Sub + Copy + Display, + SubOut: ToFloat, +{ + calculate_percentile(range.start, range.end, item) +} + +fn calculate_percentile(range_start: T, range_end: T, item: T) -> f64 +where + T: PartialOrd + std::ops::Sub + Copy + Display, + SubOut: ToFloat, +{ + if item < range_start || item > range_end { + panic!( + "Item is not within the range. {} {} {}", + item, range_start, range_end + ); + } + + let range_size = range_end - range_start; // +1 to include both ends + let position = item - range_start; // Position of item in the range + + // Calculate percentile + (position.to_f64()) / (range_size.to_f64()) +} + impl Render for CheckinGraph { fn render(&self) -> maud::Markup { let full_height = 100; @@ -208,8 +348,7 @@ impl Render for CheckinGraph { let min_time = self.checkins.iter().map(|p| p.created_at).min().unwrap(); let max_time = self.checkins.iter().map(|p| p.created_at).max().unwrap(); - let total_time = max_time - min_time; - let total_time = total_time.to_std().unwrap(); + let x_range = min_time..max_time; let min_duration_nanos = self .checkins @@ -218,7 +357,8 @@ impl Render for CheckinGraph { .min() .unwrap() / 1_000_000 - * 1_000_000; + * 1_000_000 + - 1_000_000; let min_label = humantime::format_duration(Duration::from_nanos(min_duration_nanos as u64)).to_string(); @@ -229,23 +369,28 @@ impl Render for CheckinGraph { .max() .unwrap() / 1_000_000 - * 1_000_000; + * 1_000_000 + + 1_000_000; + let max_label = humantime::format_duration(Duration::from_nanos(max_duration_nanos as u64)).to_string(); - let height_range = max_duration_nanos - min_duration_nanos; + let y_range = min_duration_nanos..max_duration_nanos; + + let calculate_x = + |time: DateTime| calculate_range_percentile(&x_range, time) * width as f64; + + let calculate_y = |duration: i64| { + height as f64 + height_padding as f64 + - calculate_range_percentile(&y_range, duration) * height as f64 + }; let points = self .checkins .iter() .map(|p| GraphPoint { - x: (((p.created_at - min_time).to_std().unwrap().as_nanos() as f64 - / total_time.as_nanos() as f64) - * width as f64), - y: ((full_height - height_padding) as f64) - - (((p.duration_nanos.unwrap() as f64 - min_duration_nanos as f64) - / height_range as f64) - * height as f64), + x: calculate_x(p.created_at), + y: calculate_y(p.duration_nanos.unwrap()), label: humantime::format_duration(Duration::from_nanos( p.duration_nanos.unwrap() as u64 )) @@ -253,43 +398,22 @@ impl Render for CheckinGraph { }) .collect::>(); - let svg_path = points - .iter() - .enumerate() - .map(|(i, GraphPoint { x, y, .. })| { - if i == 0 { - format!("M{x} {y}") - } else { - format!("L{x} {y}") - } - }) - .collect::>() - .join(" "); - html! { svg class="w-full" viewBox="0 0 200 100" { - // y max line (YAxisLine { width, y_pos: height_padding, label: format!("Max: {max_label}")}) - // y max line (YAxisLine { width, y_pos: full_height - height_padding, label: format!("Min: {min_label}")}) - // This is the actual point line - path d=(svg_path) fill="none" stroke="black" {} - - @for GraphPoint { x, y, label } in points.iter() { - // Group for Hover State - g class="group" { - // Invisible Circle to make hover state bigger - circle cx=(x) cy=(y) r=(4) class="fill-transparent stroke-transparent" {} - // Point on line - circle cx=(x) cy=(y) r=(2) class="group-hover:fill-red-500" {} - - // Label, hidden till group hover - text x=(x) y=(y + 5.0) font-size=4 class="hidden group-hover:block fill-red-500" { (label) } - } - } + (SvgPathWithPoints { + points, + stroke_width: 0.5, + path_class: "stroke-black".to_string(), + stroke_dashed: false, + point_radius: 2, + label_font_size: 4, + group_class: "hover:fill-red-500".to_string(), + }) } } } From e5bdcd9661583dbe207b9ac04f939010e3891524 Mon Sep 17 00:00:00 2001 From: Corey Alexander Date: Thu, 14 Mar 2024 23:18:54 -0400 Subject: [PATCH 2/2] Chunked graph with min and max --- src/routes/current_user/pages.rs | 150 ++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/src/routes/current_user/pages.rs b/src/routes/current_user/pages.rs index 6abfef2..b3c03a9 100644 --- a/src/routes/current_user/pages.rs +++ b/src/routes/current_user/pages.rs @@ -123,8 +123,9 @@ pub async fn show( let mut checkins_for_graph = checkins.clone(); checkins_for_graph.reverse(); - let graph = CheckinGraph { + let graph = SampledCheckinGraph { checkins: checkins_for_graph, + number_of_chunks: 10, }; html! { @@ -166,10 +167,16 @@ struct Checkin { created_at: DateTime, } -struct CheckinGraph { +struct SimpleCheckinGraph { checkins: Vec, } +struct SampledCheckinGraph { + checkins: Vec, + number_of_chunks: usize, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] struct GraphPoint { x: f64, y: f64, @@ -337,7 +344,7 @@ where (position.to_f64()) / (range_size.to_f64()) } -impl Render for CheckinGraph { +impl Render for SimpleCheckinGraph { fn render(&self) -> maud::Markup { let full_height = 100; let height_padding = 10; @@ -418,3 +425,140 @@ impl Render for CheckinGraph { } } } + +fn transpose(vec: Vec<(X, Y, Z)>) -> (Vec, Vec, Vec) +where + X: Clone, + Y: Clone, + Z: Clone, +{ + vec.into_iter().fold( + (Vec::new(), Vec::new(), Vec::new()), + |(mut v1, mut v2, mut v3), (e1, e2, e3)| { + v1.push(e1); + v2.push(e2); + v3.push(e3); + (v1, v2, v3) + }, + ) +} + +impl Render for SampledCheckinGraph { + fn render(&self) -> maud::Markup { + let full_height = 100; + let height_padding = 10; + + let width = 200; + let height = full_height - height_padding * 2; + + let min_time = self.checkins.iter().map(|p| p.created_at).min().unwrap(); + let max_time = self.checkins.iter().map(|p| p.created_at).max().unwrap(); + + let x_range = min_time..max_time; + + let min_duration_nanos = self + .checkins + .iter() + .map(|p| p.duration_nanos.unwrap()) + .min() + .unwrap() + / 1_000_000 + * 1_000_000 + - 1_000_000; + let min_label = + humantime::format_duration(Duration::from_nanos(min_duration_nanos as u64)).to_string(); + + let max_duration_nanos = self + .checkins + .iter() + .map(|p| p.duration_nanos.unwrap()) + .max() + .unwrap() + / 1_000_000 + * 1_000_000 + + 1_000_000; + + let max_label = + humantime::format_duration(Duration::from_nanos(max_duration_nanos as u64)).to_string(); + + let y_range = min_duration_nanos..max_duration_nanos; + + let calculate_x = + |time: DateTime| calculate_range_percentile(&x_range, time) * width as f64; + + let calculate_y = |duration: i64| { + height as f64 + height_padding as f64 + - calculate_range_percentile(&y_range, duration) * height as f64 + }; + + let total_count = self.checkins.len(); + + let chunk_size = (total_count / self.number_of_chunks).max(1); + let chunks = self.checkins.chunks(chunk_size); + + let min_avg_max: Vec<((f64, f64), GraphPoint, (f64, f64))> = chunks + .map(|chunk| { + let min = chunk + .iter() + .map(|p| p.duration_nanos.unwrap()) + .min() + .unwrap(); + let max = chunk + .iter() + .map(|p| p.duration_nanos.unwrap()) + .max() + .unwrap(); + let avg = chunk.iter().map(|p| p.duration_nanos.unwrap()).sum::() + / chunk.len() as i64; + + let x = calculate_x(chunk[chunk.len() / 2].created_at); + + ( + (x, calculate_y(min)), + GraphPoint { + x, + y: calculate_y(avg), + label: humantime::format_duration(Duration::from_nanos(avg as u64)) + .to_string(), + }, + (x, calculate_y(max)), + ) + }) + .collect(); + + let (min, avg, max) = transpose(min_avg_max); + + html! { + svg class="w-full" viewBox="0 0 200 100" { + + (YAxisLine { width, y_pos: height_padding, label: format!("Max: {max_label}")}) + + (YAxisLine { width, y_pos: full_height - height_padding, label: format!("Min: {min_label}")}) + + (SvgPath { + points: min, + stroke_width: 0.25, + path_class: "stroke-black".to_string(), + stroke_dashed: true, + }) + + (SvgPathWithPoints { + points: avg, + stroke_width: 0.5, + path_class: "stroke-blue-500".to_string(), + stroke_dashed: false, + point_radius: 2, + label_font_size: 4, + group_class: "hover:fill-red-500".to_string(), + }) + + (SvgPath { + points: max, + stroke_width: 0.25, + path_class: "stroke-black".to_string(), + stroke_dashed: true, + }) + } + } + } +}