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,447 @@
|
||||
use std::{
|
||||
f32::consts::{FRAC_PI_2, TAU},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, Pixels, Refineable,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, fill, point,
|
||||
prelude::*, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
axes::anchored,
|
||||
geometry::Rect,
|
||||
glide::Glide,
|
||||
paint::{at, finish, measure, place, ring, tint},
|
||||
parts::ChartTooltip,
|
||||
pie::{bearing, toward, wedge},
|
||||
plot::Format,
|
||||
scale::{compact, nice},
|
||||
tiles::{Tiles, tracked},
|
||||
};
|
||||
use crate::{
|
||||
motion,
|
||||
theme::{ActiveTheme, Density, Radius, TextSize},
|
||||
typography::{format, tabular},
|
||||
};
|
||||
|
||||
/// A measure against its target: its value, the target it aims for, and the bands from poor to good it falls in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Bullet {
|
||||
label: SharedString,
|
||||
value: f64,
|
||||
target: f64,
|
||||
bands: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Bullet {
|
||||
pub fn new(label: impl Into<SharedString>, value: f64, target: f64) -> Self {
|
||||
assert!(
|
||||
value.is_finite() && target.is_finite() && value >= 0.0 && target >= 0.0,
|
||||
"a bullet reads values of zero or more"
|
||||
);
|
||||
Self {
|
||||
label: label.into(),
|
||||
value,
|
||||
target,
|
||||
bands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where each band ends, rising: poor, then fair, then good.
|
||||
pub fn bands(mut self, ends: impl IntoIterator<Item = f64>) -> Self {
|
||||
self.bands = ends.into_iter().collect();
|
||||
assert!(
|
||||
self.bands.windows(2).all(|pair| pair[0] < pair[1])
|
||||
&& self.bands.iter().all(|end| *end > 0.0),
|
||||
"bands end in rising order"
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// The top of this bullet's scale: past its value, its target and its last band.
|
||||
fn top(&self) -> f64 {
|
||||
let most = self
|
||||
.bands
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(self.value.max(self.target), f64::max);
|
||||
nice(0.0, most.max(f64::EPSILON), 4).0.1
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures against their targets, a row each: a thin bar for the value over bands from poor to good, and a mark at the target. Hover a row to read it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct BulletChart {
|
||||
base: Div,
|
||||
id: ElementId,
|
||||
bullets: Vec<Bullet>,
|
||||
format: Format,
|
||||
}
|
||||
|
||||
impl BulletChart {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
base: div(),
|
||||
id: id.into(),
|
||||
bullets: Vec::new(),
|
||||
format: Rc::new(compact),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bullet(mut self, bullet: Bullet) -> Self {
|
||||
self.bullets.push(bullet);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
|
||||
self.format = Rc::new(format);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for BulletChart {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for BulletChart {
|
||||
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let tiles: Entity<Tiles> =
|
||||
window.use_keyed_state((self.id.clone(), "rows"), cx, |_, _| Tiles::default());
|
||||
let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover);
|
||||
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 (names, row, inset, count) = (
|
||||
pixels(theme.label_width()),
|
||||
pixels(theme.table_row(Density::Comfortable)),
|
||||
pixels(sizes.inset),
|
||||
self.bullets.len(),
|
||||
);
|
||||
let length = (f32::from(bounds.size.width) - names - inset).max(0.0);
|
||||
let format = self.format.clone();
|
||||
let labels: Vec<Div> = self
|
||||
.bullets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, bullet)| {
|
||||
let reading = format!("{} / {}", format(bullet.value), format(bullet.target));
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top(Pixels::from(row * ix as f32))
|
||||
.w(Pixels::from(names))
|
||||
.h(Pixels::from(row))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.justify_center()
|
||||
.pr_3()
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg)
|
||||
.child(bullet.label.clone()),
|
||||
)
|
||||
.child(
|
||||
tabular(div())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(reading),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let tooltip = hover.map(|ix| {
|
||||
let bullet = &self.bullets[ix];
|
||||
let reach = if bullet.target > 0.0 {
|
||||
format::percent(bullet.value / bullet.target, 0, false)
|
||||
} else {
|
||||
"—".into()
|
||||
};
|
||||
let card = ChartTooltip::new(bullet.label.clone())
|
||||
.row(Some(tint(&colors, 0)), "Value", format(bullet.value))
|
||||
.row(None, "Target", format(bullet.target))
|
||||
.row(None, "Of target", reach);
|
||||
let x = names + length * (bullet.value / bullet.top()) as f32;
|
||||
anchored(
|
||||
(x, row * ix as f32 + row / 2.0),
|
||||
x > f32::from(bounds.size.width) * 0.6,
|
||||
card,
|
||||
)
|
||||
});
|
||||
let rows: Vec<(Vec<f32>, f32, f32)> = self
|
||||
.bullets
|
||||
.iter()
|
||||
.map(|bullet| {
|
||||
let share = |value: f64| (value / bullet.top()) as f32;
|
||||
(
|
||||
bullet.bands.iter().map(|end| share(*end)).collect(),
|
||||
share(bullet.value),
|
||||
share(bullet.target),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (stroke, corner, palette) = (
|
||||
sizes.stroke.to_pixels(rem),
|
||||
theme.radius(Radius::Sm).to_pixels(rem),
|
||||
colors.clone(),
|
||||
);
|
||||
let mut root = div()
|
||||
.relative()
|
||||
.w_full()
|
||||
.h(theme.table_row(Density::Comfortable) * count as f32);
|
||||
root.style().refine(self.base.style());
|
||||
tracked(
|
||||
root,
|
||||
(self.id.clone(), "bullets").into(),
|
||||
&tiles,
|
||||
move |(_, y)| {
|
||||
let at = (y / row).floor();
|
||||
(at >= 0.0 && (at as usize) < count).then_some(at as usize)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let origin = bounds.origin;
|
||||
for (ix, (bands, value, target)) in rows.iter().enumerate() {
|
||||
let top = row * ix as f32;
|
||||
let band = |share: f32| Rect {
|
||||
x: names,
|
||||
y: top + row * 0.2,
|
||||
w: length * share,
|
||||
h: row * 0.6,
|
||||
};
|
||||
for (step, end) in bands.iter().enumerate().rev() {
|
||||
let strength = 0.04 + 0.04 * (bands.len() - 1 - step) as f32;
|
||||
window.paint_quad(
|
||||
fill(place(origin, band(*end)), palette.fg.opacity(strength))
|
||||
.corner_radii(corner),
|
||||
);
|
||||
}
|
||||
let bar = Rect {
|
||||
x: names,
|
||||
y: top + row * 0.38,
|
||||
w: length * value,
|
||||
h: row * 0.24,
|
||||
};
|
||||
let ink = if hover.is_none_or(|on| on == ix) {
|
||||
tint(&palette, 0)
|
||||
} else {
|
||||
tint(&palette, 0).opacity(0.5)
|
||||
};
|
||||
window.paint_quad(fill(place(origin, bar), ink).corner_radii(corner));
|
||||
let mark = Bounds::new(
|
||||
at(
|
||||
origin,
|
||||
(
|
||||
names + length * target - f32::from(stroke) / 2.0,
|
||||
top + row * 0.14,
|
||||
),
|
||||
),
|
||||
size(stroke, Pixels::from(row * 0.72)),
|
||||
);
|
||||
window.paint_quad(fill(mark, palette.fg));
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.children(labels)
|
||||
.children(tooltip)
|
||||
.child(measure(tiles, |tiles| &mut tiles.bounds))
|
||||
}
|
||||
}
|
||||
|
||||
/// Goals as rings around one center, each filling clockwise toward its target; the center reads the goal under the pointer, or the first.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProgressChart {
|
||||
base: Div,
|
||||
id: ElementId,
|
||||
goals: Vec<(SharedString, f64, f64)>,
|
||||
format: Format,
|
||||
}
|
||||
|
||||
impl ProgressChart {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
base: div(),
|
||||
id: id.into(),
|
||||
goals: Vec::new(),
|
||||
format: Rc::new(compact),
|
||||
}
|
||||
}
|
||||
|
||||
/// A goal: how far it has come of its target.
|
||||
pub fn goal(mut self, name: impl Into<SharedString>, value: f64, target: f64) -> Self {
|
||||
assert!(
|
||||
value.is_finite() && value >= 0.0 && target.is_finite() && target > 0.0,
|
||||
"a goal has a positive target and a value of zero or more"
|
||||
);
|
||||
self.goals.push((name.into(), value, target));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
|
||||
self.format = Rc::new(format);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ProgressChart {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProgressChart {
|
||||
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let rings: Entity<Tiles> =
|
||||
window.use_keyed_state((self.id.clone(), "rings"), cx, |_, _| Tiles::default());
|
||||
let glide: Entity<Glide> =
|
||||
window.use_keyed_state((self.id.clone(), "glide"), cx, |_, _| Glide::default());
|
||||
let shares: Vec<f64> = self
|
||||
.goals
|
||||
.iter()
|
||||
.map(|(_, value, target)| value / target)
|
||||
.collect();
|
||||
let slow = motion::duration(motion::SLOW, cx);
|
||||
let (glided, moving) = glide.update(cx, |glide, _| {
|
||||
glide.follow(std::slice::from_ref(&shares), slow)
|
||||
});
|
||||
if moving {
|
||||
window.request_animation_frame();
|
||||
}
|
||||
let glided = glided.into_iter().next().expect("one row of shares");
|
||||
let (bounds, hover) = (rings.read(cx).bounds, rings.read(cx).hover);
|
||||
let theme = cx.theme();
|
||||
let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size());
|
||||
let side = f32::from(bounds.size.width).min(f32::from(bounds.size.height));
|
||||
let count = self.goals.len();
|
||||
let gap = f32::from(sizes.stroke.to_pixels(rem)) * 2.0;
|
||||
let thick = (side / 2.0 / count.max(1) as f32 - gap)
|
||||
.min(side * 0.09)
|
||||
.max(0.0);
|
||||
let radius = move |ix: usize| side / 2.0 - thick / 2.0 - (thick + gap) * ix as f32;
|
||||
let shown = hover.unwrap_or(0);
|
||||
let format = self.format.clone();
|
||||
let middle = self.goals.get(shown).map(|(name, _, _)| {
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
tabular(div())
|
||||
.text_size(theme.text_size(TextSize::Lg))
|
||||
.text_color(colors.fg)
|
||||
.child(format::percent(shares[shown], 0, false)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(name.clone()),
|
||||
)
|
||||
});
|
||||
let legend = self
|
||||
.goals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (name, value, target))| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when(hover.is_some_and(|on| on != ix), |row| row.opacity(0.5))
|
||||
.child(
|
||||
div()
|
||||
.size(theme.status_dot())
|
||||
.rounded_full()
|
||||
.bg(tint(&colors, ix)),
|
||||
)
|
||||
.child(div().text_color(colors.fg).child(name.clone()))
|
||||
.child(tabular(div()).text_color(colors.fg_muted).child(format!(
|
||||
"{} / {}",
|
||||
format(*value),
|
||||
format(*target)
|
||||
)))
|
||||
});
|
||||
let tooltip = hover.map(|ix| {
|
||||
let (name, value, target) = &self.goals[ix];
|
||||
let card = ChartTooltip::new(name.clone())
|
||||
.row(
|
||||
Some(tint(&colors, ix)),
|
||||
"Done",
|
||||
format::percent(value / target, 0, false),
|
||||
)
|
||||
.row(None, "Of", format(*target));
|
||||
anchored((side / 2.0 + radius(ix), side / 2.0), false, card)
|
||||
});
|
||||
let palette = colors.clone();
|
||||
let mut root = div().flex().items_center().gap_8();
|
||||
root.style().refine(self.base.style());
|
||||
let dial = tracked(
|
||||
div().relative().flex_none().size(sizes.height * 0.7),
|
||||
(self.id.clone(), "rings").into(),
|
||||
&rings,
|
||||
move |place| {
|
||||
let (_, distance) = bearing((side / 2.0, side / 2.0), place);
|
||||
(0..count).find(|ix| (distance - radius(*ix)).abs() <= thick / 2.0 + gap / 2.0)
|
||||
},
|
||||
);
|
||||
root.child(
|
||||
dial.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let center = bounds.origin
|
||||
+ point(Pixels::from(side / 2.0), Pixels::from(side / 2.0));
|
||||
for (ix, share) in glided.iter().enumerate() {
|
||||
let (middle, ink) = (radius(ix), tint(&palette, ix));
|
||||
let ink = if hover.is_some_and(|on| on != ix) {
|
||||
ink.opacity(0.35)
|
||||
} else {
|
||||
ink
|
||||
};
|
||||
let band = (middle - thick / 2.0, middle + thick / 2.0);
|
||||
if let Some(track) = wedge(center, band, (-FRAC_PI_2, TAU), 0.0) {
|
||||
finish(track, palette.fg.opacity(0.06), window);
|
||||
}
|
||||
let sweep = (*share as f32).clamp(0.0, 1.0) * TAU;
|
||||
if sweep <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(arc) = wedge(center, band, (-FRAC_PI_2, sweep), 0.0) {
|
||||
finish(arc, ink, window);
|
||||
}
|
||||
for angle in [-FRAC_PI_2, -FRAC_PI_2 + sweep] {
|
||||
ring(
|
||||
toward(center, middle, angle),
|
||||
Pixels::from(thick / 2.0),
|
||||
(ink, ink),
|
||||
Pixels::ZERO,
|
||||
window,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.children(middle)
|
||||
.children(tooltip)
|
||||
.child(measure(rings, |rings| &mut rings.bounds)),
|
||||
)
|
||||
.child(div().flex().flex_col().gap_2().children(legend))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user