398 lines
13 KiB
Rust
398 lines
13 KiB
Rust
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<usize> {
|
|
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<Pixels>, radius: f32, angle: f32) -> Point<Pixels> {
|
|
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<Pixels>,
|
|
(inner, outer): (f32, f32),
|
|
(start, sweep): (f32, f32),
|
|
gap: f32,
|
|
) -> Option<PathBuilder> {
|
|
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<usize>,
|
|
off: Option<usize>,
|
|
since: Option<Instant>,
|
|
}
|
|
|
|
impl Lift {
|
|
/// Moves to `next`; whether anything changed.
|
|
fn point(&mut self, next: Option<usize>) -> 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<f32>, 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<f32> {
|
|
(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<Pixels>,
|
|
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<ElementId>) -> Self {
|
|
Self {
|
|
base: div(),
|
|
id: id.into(),
|
|
slices: Vec::new(),
|
|
center: None,
|
|
}
|
|
}
|
|
|
|
pub fn slice(mut self, name: impl Into<SharedString>, 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<SharedString>, label: impl Into<SharedString>) -> 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<Ring> =
|
|
window.use_keyed_state((self.id.clone(), "ring"), cx, |_, _| Ring::default());
|
|
let raw: Vec<f64> = 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<Hsla> = 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]);
|
|
}
|
|
}
|