T15 Charts: cartesian with axes, legend, crosshair, tooltip, zoom, annotations and export (T15a); pie, donut, radar, heatmaps, treemap, sunburst, waterfall and spreads (T15b); sankey, funnel, gantt, force graph, chord, parallel coordinates, bullet and progress (T15c)
This commit is contained in:
44 files changed
+8639
-35
No files matched your search
@@ -0,0 +1,397 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user