use std::{ f32::consts::{FRAC_PI_2, PI, TAU}, time::{Duration, Instant}, }; use gpui::{ App, Bounds, Div, ElementId, Entity, Hsla, InteractiveElement, IntoElement, MouseMoveEvent, ParentElement, PathBuilder, Pixels, Point, Refineable, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, point, }; use super::{ axes::anchored, glide::Glide, paint::{finish, measure, tint}, parts::{ChartLegend, ChartTooltip}, scale::compact, }; use crate::{ motion, theme::{ActiveTheme, TextSize}, typography::{format, tabular}, }; /// Arcs for shares of a whole, clockwise from twelve o'clock: each a start and a sweep in radians. pub(crate) fn sweeps(values: &[f64]) -> Vec<(f32, f32)> { let total: f64 = values.iter().filter(|value| **value > 0.0).sum(); let mut start = -FRAC_PI_2; values .iter() .map(|value| { let sweep = if total > 0.0 { (value.max(0.0) / total) as f32 * TAU } else { 0.0 }; let arc = (start, sweep); start += sweep; arc }) .collect() } /// The angle toward `at` from `center`, from twelve o'clock on, and how far it lies. pub(crate) fn bearing(center: (f32, f32), at: (f32, f32)) -> (f32, f32) { let (dx, dy) = (at.0 - center.0, at.1 - center.1); ( (dy.atan2(dx) + FRAC_PI_2).rem_euclid(TAU) - FRAC_PI_2, dx.hypot(dy), ) } /// Which arc holds the direction of `at` from `center`, between radii `inner` and `outer`. pub(crate) fn arc_at( arcs: &[(f32, f32)], center: (f32, f32), at: (f32, f32), inner: f32, outer: f32, ) -> Option { let (angle, distance) = bearing(center, at); if distance < inner || distance > outer { return None; } arcs.iter() .position(|(start, sweep)| angle >= *start && angle < start + sweep) } /// A point `radius` from `center` toward `angle`. pub(crate) fn toward(center: Point, radius: f32, angle: f32) -> Point { center + point( Pixels::from(radius * angle.cos()), Pixels::from(radius * angle.sin()), ) } /// A ring segment's outline with `gap` taken evenly off both sides: out along the outer arc and back along the inner one, or to where the sides meet; none when too thin to draw. pub(crate) fn wedge( center: Point, (inner, outer): (f32, f32), (start, sweep): (f32, f32), gap: f32, ) -> Option { let half = gap / 2.0; let meet = if sweep < PI { half / (sweep / 2.0).sin().max(f32::EPSILON) } else { half }; let inner = inner.max(meet); if sweep <= 0.0 || inner >= outer { return None; } let arc = |radius: f32| { let inset = if radius > 0.0 { (half / radius).min(1.0).asin().min(sweep / 2.0) } else { sweep / 2.0 }; (start + inset, sweep - inset * 2.0) }; let steps = ((sweep / TAU) * 120.0).ceil().max(2.0) as usize; let along = |step: usize| step as f32 / steps as f32; let mut path = PathBuilder::fill(); let (from, span) = arc(outer); path.move_to(toward(center, outer, from)); (1..=steps).for_each(|step| path.line_to(toward(center, outer, from + span * along(step)))); let (from, span) = arc(inner); (0..=steps) .rev() .for_each(|step| path.line_to(toward(center, inner, from + span * along(step)))); path.close(); Some(path) } /// The slice under the pointer, the one it left, and when it moved: the first lifts out as the second settles. #[derive(Default)] struct Lift { on: Option, off: Option, since: Option, } impl Lift { /// Moves to `next`; whether anything changed. fn point(&mut self, next: Option) -> bool { if self.on == next { return false; } (self.off, self.on, self.since) = (self.on, next, Some(Instant::now())); true } /// How far each of `count` slices has lifted, zero to one, and whether any still moves. fn shares(&self, count: usize, length: Duration) -> (Vec, bool) { let share = self.since.map_or(1.0, |since| { (since.elapsed().as_secs_f32() / length.as_secs_f32()).min(1.0) }); let eased = motion::ease_out_cubic(share); let lift = |ix: usize| match (self.on == Some(ix), self.off == Some(ix)) { (true, _) => eased, (_, true) => 1.0 - eased, _ => 0.0, }; ((0..count).map(lift).collect(), share < 1.0) } } /// How much each slice fades while another lifts: by the most any other has lifted, less as it lifts itself. fn fades(lifts: &[f32]) -> Vec { (0..lifts.len()) .map(|ix| { let others = lifts .iter() .enumerate() .filter(|(other, _)| *other != ix) .fold(0.0f32, |most, (_, lift)| most.max(*lift)); others * (1.0 - lifts[ix]) }) .collect() } /// A ring chart's own state: its box, the lift under the pointer, and a glide between values. #[derive(Default)] struct Ring { bounds: Bounds, lift: Lift, glide: Glide, } /// Parts of a whole as slices, clockwise from twelve in the order given; `donut` hollows the middle for a total. Hover lifts a slice and reads its share. #[derive(IntoElement)] pub struct PieChart { base: Div, id: ElementId, slices: Vec<(SharedString, f64)>, center: Option<(SharedString, SharedString)>, } impl PieChart { pub fn new(id: impl Into) -> Self { Self { base: div(), id: id.into(), slices: Vec::new(), center: None, } } pub fn slice(mut self, name: impl Into, value: f64) -> Self { assert!( value.is_finite() && value >= 0.0, "a slice needs a value of zero or more" ); self.slices.push((name.into(), value)); self } /// Hollows the middle, which reads `value` over `label` until a slice is hovered. pub fn donut(mut self, value: impl Into, label: impl Into) -> Self { self.center = Some((value.into(), label.into())); self } } impl Styled for PieChart { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl RenderOnce for PieChart { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let ring: Entity = window.use_keyed_state((self.id.clone(), "ring"), cx, |_, _| Ring::default()); let raw: Vec = self.slices.iter().map(|(_, value)| *value).collect(); let (slow, fast) = ( motion::duration(motion::SLOW, cx), motion::duration(motion::FAST, cx), ); let (glided, gliding) = ring.update(cx, |ring, _| { ring.glide.follow(std::slice::from_ref(&raw), slow) }); let arcs = sweeps(&glided.into_iter().next().expect("one ring of values")); let (lifts, lifting) = ring.read(cx).lift.shares(arcs.len(), fast); if gliding || lifting { window.request_animation_frame(); } let hover = ring.read(cx).lift.on.filter(|ix| *ix < arcs.len()); let theme = cx.theme(); let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); let bounds = ring.read(cx).bounds; let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); let lift = pixels(sizes.lift); let outer = (width.min(height) / 2.0 - lift).max(0.0); let inner = if self.center.is_some() { outer * 0.64 } else { 0.0 }; let gap = if raw.iter().filter(|value| **value > 0.0).count() > 1 { pixels(sizes.stroke) } else { 0.0 }; let total: f64 = raw.iter().sum(); let share = |ix: usize| if total > 0.0 { raw[ix] / total } else { 0.0 }; let fills: Vec = fades(&lifts) .iter() .enumerate() .map(|(ix, fade)| tint(&colors, ix).opacity(1.0 - 0.45 * fade)) .collect(); let tooltip = hover.filter(|_| self.center.is_none()).map(|ix| { let (start, sweep) = arcs[ix]; let middle = start + sweep / 2.0; let reach = outer + lift; let place = ( width / 2.0 + reach * middle.cos(), height / 2.0 + reach * middle.sin(), ); let card = ChartTooltip::new(self.slices[ix].0.clone()) .row( Some(tint(&colors, ix)), "Share", format::percent(share(ix), 1, false), ) .row(None, "Value", compact(raw[ix])); anchored(place, middle.cos() < 0.0, card) }); let middle = self.center.clone().map(|(value, label)| { let (big, small) = match hover { Some(ix) => ( format::percent(share(ix), 1, false).into(), self.slices[ix].0.clone(), ), None => (value, label), }; div() .absolute() .inset_0() .flex() .flex_col() .items_center() .justify_center() .child( tabular(div()) .text_size(theme.text_size(TextSize::Xl)) .text_color(colors.fg) .child(big), ) .child( div() .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_muted) .child(small), ) }); let legend = self.slices.iter().enumerate().fold( ChartLegend::new((self.id.clone(), "legend")), |legend, (ix, (name, _))| legend.entry(tint(&colors, ix), name.clone(), false), ); let (moved, left, measured, hit) = (ring.clone(), ring.clone(), ring.clone(), arcs.clone()); let mut frame = div().w_full().h(sizes.height); frame.style().refine(self.base.style()); frame.flex().flex_col().gap_2().child(legend).child( div() .id((self.id.clone(), "ring")) .relative() .flex_1() .min_h_0() .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { let center = moved.read(cx).bounds.center(); let at = (f32::from(event.position.x), f32::from(event.position.y)); let next = arc_at( &hit, (f32::from(center.x), f32::from(center.y)), at, inner, outer + lift, ); moved.update(cx, |ring, cx| { if ring.lift.point(next) { cx.notify(); } }) }) .on_hover(move |inside, _, cx| { if !*inside { left.update(cx, |ring, cx| { if ring.lift.point(None) { cx.notify(); } }) } }) .child( canvas( |_, _, _| {}, move |bounds, _, window, _| { let center = bounds.center(); for (ix, (start, sweep)) in arcs.iter().enumerate() { let shifted = toward(center, lift * lifts[ix], start + sweep / 2.0); if let Some(path) = wedge(shifted, (inner, outer), (*start, *sweep), gap) { finish(path, fills[ix], window); } } }, ) .absolute() .inset_0(), ) .children(middle) .children(tooltip) .child(measure(measured, |ring| &mut ring.bounds)), ) } } #[cfg(test)] mod tests { use std::f32::consts::{FRAC_PI_2, TAU}; use super::*; #[test] fn slices_sweep_by_share_from_twelve_oclock() { let arcs = sweeps(&[1.0, 3.0]); assert_eq!(arcs[0], (-FRAC_PI_2, TAU / 4.0)); assert_eq!(arcs[1].1, TAU * 0.75); assert_eq!( arc_at(&arcs, (0.0, 0.0), (5.0, -5.0), 0.0, 20.0), Some(0), "up and right is the first quarter" ); assert_eq!(arc_at(&arcs, (0.0, 0.0), (-5.0, 5.0), 0.0, 20.0), Some(1)); assert_eq!( arc_at(&arcs, (0.0, 0.0), (50.0, 0.0), 0.0, 20.0), None, "outside the ring" ); } #[test] fn a_lifted_slice_fades_the_others() { assert_eq!(fades(&[0.0, 1.0, 0.0]), [1.0, 0.0, 1.0]); assert_eq!( fades(&[0.5, 0.5]), [0.25, 0.25], "a slice settling as another lifts" ); assert_eq!(fades(&[0.0, 0.0]), [0.0, 0.0]); } }