use gpui::SharedString; /// A named run of values, one for each of the chart's categories. #[derive(Clone, Debug, PartialEq)] pub struct Series { pub(crate) name: SharedString, pub(crate) values: Vec, } impl Series { pub fn new(name: impl Into, values: impl IntoIterator) -> Self { let values: Vec = values.into_iter().collect(); assert!( values.iter().all(|value| value.is_finite()), "a series needs finite values" ); Self { name: name.into(), values, } } } /// A named cloud of points, each an x and a y, sized for bubbles when `sizes` are given. #[derive(Clone, Debug, PartialEq)] pub struct Points { pub(crate) name: SharedString, pub(crate) points: Vec<(f64, f64)>, pub(crate) sizes: Option>, } impl Points { pub fn new( name: impl Into, points: impl IntoIterator, ) -> Self { let points: Vec<(f64, f64)> = points.into_iter().collect(); assert!( points.iter().all(|(x, y)| x.is_finite() && y.is_finite()), "points need finite values" ); Self { name: name.into(), points, sizes: None, } } /// A size for each point, drawn as its area; makes a bubble chart. pub fn sizes(mut self, sizes: impl IntoIterator) -> Self { let sizes: Vec = sizes.into_iter().collect(); assert_eq!( sizes.len(), self.points.len(), "a bubble needs a size for each point" ); assert!( sizes.iter().all(|size| size.is_finite() && *size >= 0.0), "bubble sizes are not negative" ); self.sizes = Some(sizes); self } } /// Each series' bottom and top at each category when stacked on the ones before it; negatives stack down from zero. pub(crate) fn stacked(series: &[&[f64]]) -> Vec> { let count = series.first().map_or(0, |values| values.len()); let mut up = vec![0.0; count]; let mut down = vec![0.0; count]; series .iter() .map(|values| { values .iter() .enumerate() .map(|(ix, value)| { let base = if *value >= 0.0 { &mut up[ix] } else { &mut down[ix] }; let bottom = *base; *base += value; (bottom, *base) }) .collect() }) .collect() } /// The lowest and highest a chart must show: zero, and every value or stacked total. pub(crate) fn extent(values: impl Iterator) -> (f64, f64) { values.fold((0.0, 0.0), |(low, high), value| { (low.min(value), high.max(value)) }) } /// Tangents for a smooth line through `points` that never overshoots between them (Fritsch–Carlson). pub(crate) fn tangents(points: &[(f32, f32)]) -> Vec { let count = points.len(); if count < 2 { return vec![0.0; count]; } let slopes: Vec = points .windows(2) .map(|pair| (pair[1].1 - pair[0].1) / (pair[1].0 - pair[0].0)) .collect(); let mut tangents: Vec = (0..count) .map(|ix| match ix { 0 => slopes[0], last if last == count - 1 => slopes[count - 2], ix if slopes[ix - 1] * slopes[ix] <= 0.0 => 0.0, ix => (slopes[ix - 1] + slopes[ix]) / 2.0, }) .collect(); for (ix, slope) in slopes.iter().enumerate() { if *slope == 0.0 { tangents[ix] = 0.0; tangents[ix + 1] = 0.0; continue; } let (a, b) = (tangents[ix] / slope, tangents[ix + 1] / slope); let length = a.hypot(b); if length > 3.0 { tangents[ix] = 3.0 * a / length * slope; tangents[ix + 1] = 3.0 * b / length * slope; } } tangents } /// A field as CSV reads it: quoted when it holds a comma, a quote or a line break. fn field(text: &str) -> String { if text.contains([',', '"', '\n']) { format!("\"{}\"", text.replace('"', "\"\"")) } else { text.to_string() } } /// Rows of comma-separated text: a header of `labels`, then each series' name and values. pub(crate) fn csv(labels: &[SharedString], series: &[Series]) -> String { let head = std::iter::once(String::new()) .chain(labels.iter().map(|label| field(label))) .collect::>() .join(","); let rows = series.iter().map(|series| { std::iter::once(field(&series.name)) .chain(series.values.iter().map(|value| value.to_string())) .collect::>() .join(",") }); std::iter::once(head) .chain(rows) .collect::>() .join("\n") } /// Rows of comma-separated text: a header, then each point's series, x, y and size when it has one. pub(crate) fn points_csv(clouds: &[Points]) -> String { let rows = clouds.iter().flat_map(|cloud| { cloud.points.iter().enumerate().map(move |(ix, (x, y))| { let size = cloud .sizes .as_ref() .map_or(String::new(), |sizes| sizes[ix].to_string()); format!("{},{x},{y},{size}", field(&cloud.name)) }) }); std::iter::once("series,x,y,size".to_string()) .chain(rows) .collect::>() .join("\n") } #[cfg(test)] mod tests { use super::*; #[test] fn stacks_rise_from_zero_and_negatives_fall() { let stacks = stacked(&[&[2.0, -1.0], &[3.0, -2.0]]); assert_eq!( stacks, [ vec![(0.0, 2.0), (0.0, -1.0)], vec![(2.0, 5.0), (-1.0, -3.0)] ] ); assert_eq!(extent([3.0, -2.0, 7.0].into_iter()), (-2.0, 7.0)); assert_eq!( extent([3.0, 4.0].into_iter()), (0.0, 4.0), "zero stays in view" ); } #[test] fn a_smooth_line_flattens_at_peaks_and_never_overshoots() { let points = [(0.0, 0.0), (1.0, 2.0), (2.0, 1.0), (3.0, 1.0)]; let tangents = tangents(&points); assert_eq!(tangents[1], 0.0, "a peak is flat"); assert_eq!( (tangents[2], tangents[3]), (0.0, 0.0), "a level run stays level" ); } #[test] fn csv_has_a_header_and_a_row_per_series() { let labels = ["Jan", "Feb"].map(SharedString::from); let text = csv(&labels, &[Series::new("Sales, net", [1.0, 2.5])]); assert_eq!(text, ",Jan,Feb\n\"Sales, net\",1,2.5"); let points = points_csv(&[ Points::new("Paid", [(1.0, 2.0)]), Points::new("Cities", [(3.0, 4.0)]).sizes([9.0]), ]); assert_eq!(points, "series,x,y,size\nPaid,1,2,\nCities,3,4,9"); } }